ANALYSIS.md 19 KB

Analysis of the tigr-m2 project (2026-09-16)

A GNU Modula-2 (gm2 -fiso) binding of the tigr graphic library, plus a few test programs in modula2/examples. The C library is vendored in tigr-master/ (v3.2).

How it is built

From modula2/ (working directory must contain tigr.c, tigr.def, helper.mod, helper.def):

gcc -c tigr.c
gm2 -fiso -c helper.mod
gm2 -fiso -I. helper.o tigr.o <example>.mod -o <example> -lGL -lX11

The -s flag shown in the README is optional and worked fine in testing.

What works

Verified by building and running on this machine (X11 session):

  • Essai1, demo, clip, clavier, flags, update, hello, shader all compile, link and run as windows; headless runs without a window and writes a correct 320x240 PNG; opengl opens two windows and drives the raw GL path (magenta glClearColor/glClear visible in Window2). Essai2 (qualified imports) and Essai3 build. invaders (new, no assets) is a playable Space Invaders game using only tigrFillRect + the built-in font: two-frame invader animation, accelerating movement, aimed multiple bombs, bullet-vs-bomb scoring and autofire. Verified in live captures: bombs (orange #FFA028) raining on every shot and sprite frames toggling between frames. snake (new, no assets) is a classic Snake game on an 8px grid - verified moving, eating/growing and the wall-collision "GAME OVER" state. pong (new, no assets) is a classic Pong game in a 640x400 window - verified serves, wall/paddle bounces, scoring and the match-over restart. essai4.mod is not a tigr example: it is a standalone PIM-style bit-twiddling test (SHIFT/AND/OR on CARDINAL) that the ISO dialect does not accept - left as is.
  • Window creation, tigrClear, tigrFill, tigrLine, tigrRect, tigrCircle, tigrFillCircle, tigrClip, tigrBlit, tigrBlitAlpha, tigrMouse, tigrKeyDown, tigrReadChar, tigrTime, tigrTextWidth, tigrTextHeight, tigrPrint, tigrLoadImage, tigrReadFile.
  • TPixelType passed/returned by value matches the C ABI (checked with tigrGet/tigrPlot).
  • The Tigr record layout (tigr.def) matches the C struct Tigr (tigr.h) field-for-field.
  • tfont and the built-in font work; text metrics are correct.

Bugs found

BUG 1 - incomplete EXPORT UNQUALIFIED list (fixed 2026-09-16)

Only ~35 of ~110 identifiers declared in tigr.def were in the EXPORT UNQUALIFIED list, so the rest could not be imported at all (GM2 rejects them, even with qualified names). Affected:

  • Functions: tigrBeginOpenGL, tigrSetPostShader, tigrGet, tigrPlot, tigrBlitTint, tigrBlitMode, tigrLoadFont, tigrFreeFont, tigrTouch, tigrScrollWheel, tigrShowKeyboard, tigrLoadImageMem, tigrSaveImage, tigrInflate, tigrDecodeUTF8.
  • Types: TigrType, intPtr, shortRealPtr, TigrFontTypePtr, TigrFontType, TigrGlyphType, TCodepageType, TigrTouchPoint, TigrTouchPointPtr.
  • Constants: TIGR_FIXED, TIGR_KEEP_ALPHA, TIGR_BLEND_ALPHA and almost all TK_* key codes (only ESCAPE, SPACE, LEFT, RIGHT were exported).

All of them are now in the export list (verified: tigrGet, intPtr, tigrLoadFont, ... now compile in both qualified and unqualified form; demo, flags, clavier, Essai1, Essai2, Essai3 still build).

BUG 2 - TIGR_BLEND_ALPHA has the wrong value (fixed 2026-09-16)

  • tigr.def: TIGR_BLEND_ALPHA = -1
  • tigr.h: TIGR_BLEND_ALPHA = 1 (enum, default blit mode)

blitMode is used arithmetically in tigr.c (lines 470, 591, 643), so a client passing -1 would break alpha blending. Fix: it is now set to 1. This also made the constant usable (it is exported).

BUG 3 - TCodepageType enum values are wrong (fixed 2026-09-16)

  • tigr.def: TCodepageType = (TCP_ASCII, TCP_1252, TCP_UTF32) -> 0, 1, 2
  • tigr.h: TCP_ASCII = 0, TCP_1252 = 1252, TCP_UTF32 = 12001

A Modula-2 enumerated type cannot hold non-consecutive ordinals, so the enumeration was replaced by plain constants:

TCP_ASCII   = 0;
TCP_1252    = 1252;
TCP_UTF32   = 12001;

tigrLoadFont takes a CARDINAL codepage, so clients can now pass the correct values. The TCodepageType type and the stray TCodepage variable were removed from tigr.def.

BUG 4 - DynamicStrings.String used as a C const char* (fixed 2026-09-16)

A GNU Modula-2 String (from DynamicStrings) is a record whose character buffer is at offset 0 but is not NUL-terminated and is capped at 127 bytes (MaxBuf). Passing an InitString result to a C function that expects a const char* only worked while the freshly-allocated memory happened to be zeroed.

Fix applied in tigr.def (GNU Modula-2 maps ARRAY OF CHAR in a FOR "C" module onto char*/const char*, including NUL-terminated string literals):

procedure before after
tigrWindow title: String title: ARRAY OF CHAR
tigrPrint text: String text: ARRAY OF CHAR
tigrReadFile returns String (raw char*) returns ADDRESS
tigrDecodeUTF8 text: String; ... ): String text: ADDRESS; cp: intPtr): ADDRESS
tigrEncodeUTF8 text: String; ... ): String text: ADDRESS; cp: INTEGER): ADDRESS

Consequences for users:

  • Window title and tigrPrint text are now ordinary string literals / ARRAY OF CHAR (e.g. tigrWindow(320, 240, "Clip", 0)), instead of requiring InitString.
  • tigrReadFile returns a NUL-terminated char* as ADDRESS. To use the contents in M2, wrap it: InitStringCharStar(raw) then CopyOut(arr, s) (used this way in demo.mod).
  • tigrDecodeUTF8/tigrEncodeUTF8 operate on ADDRESS buffers (pass ADR(buffer)); they return the next position as ADDRESS. Do not use them with DynamicStrings.String values.

The FROM DynamicStrings IMPORT String; import was removed from tigr.def (no longer needed).

All examples were updated to the new API: demo.mod, Essai1.mod, clavier.mod, flags.mod (they were passing InitString(...) results before), and clip.mod now compiles as-is because its string literal/constant arguments are accepted directly.

BUG 5 - example programs (fixed 2026-09-16)

  • examples/clip/clip.mod did not compile (tigrWindow(320, 240, "Clip", 0) passed a string literal where String was expected). This was the same String/char* mismatch as BUG 4, so fixing tigr.def fixed clip.mod and it now compiles and runs unchanged.
  • examples/flags/flags.mod crashed immediately at old line 184: the toggle (TogglePtr) variable was never initialized (dereferences NIL), modeChange was never set, the loop ran 0..numToggles (one too many iterations), keys never un-set checked, and window recreation only ran when flags > newFlags. Fixed:
    • toggle := CAST(TogglePtr, ADR(toggles[i])); inside the loop.
    • loop is FOR i := 0 TO numToggles - 1.
    • pressing a toggle key flips it: toggle^.checked := 1 - toggle^.checked.
    • recreate on any change (flags # newFlags).
    • modeFlags := TIGR_AUTO + TIGR_RETINA and modeChange := (flags AND modeFlags) # (newFlags AND modeFlags); since ISO rejects CARDINAL AND/OR, bit masking is done via CAST(BITSET, n) * CAST(BITSET, mask) (set intersection).
    • drawToggle's stride parameter is CARDINAL (was INTEGER, receiving a CARDINAL).
    • loop condition now AND (window open AND ESC not pressed) so ESC quits, matching the C example.
  • examples/unicode/update.mod was an unfinished stub (missing : in the VAR block, empty body). Completed into a working bouncing-ball example that updates position each frame on the tigr window (keeps the original x/y/posx/posy variable names).
  • examples/Essai2/Essai2.mod was a stub. Completed into a minimal window test that exercises the qualified-import style (IMPORT helper, tigr): builds a TPixelType with helper.tigrRGB(80H, 90H, 0A0H), clears the window with it, and closes on ESC.

Examples from the original tigr tree (ported and tested 2026-09-16)

The original tigr-master/ tree (sources identical to the bundled copy) also shipped headless, hello, opengl and shader. modula2/examples/ had empty placeholders for headless/, opengl/ and shader/; hello/ did not exist. All four were ported and verified:

  • headless/headless.mod - off-screen bitmap (tigrBitmap) + tigrPrint + tigrSaveImage("headless.png", bmp); no window at all. Produces a valid 320x240 RGBA PNG whose corner pixel is exactly tigrRGB(80H, 90H, 0A0H) = (128, 144, 160) and which contains the rendered white text. Note: C returns !err, i.e. 1 = success - check result = 0 for failure, not # 0.
  • hello/hello.mod - the trivial windowed "Hello, world." (blue clear + white text). Runs fine.
  • opengl/opengl.mod - two windows; Window2 clears the raw GL framebuffer magenta via tigrBeginOpenGL + glClearColor(1,0,1,1) + glClear(GL_COLOR_BUFFER_BIT) and then blits tigr text over it. Verified by screenshot: the window is dominated by #FF00FF. The GL functions are called through a tiny DEFINITION MODULE FOR "C" gl; (opengl/gl.def).
  • shader/shader.mod - tigrSetPostShader with the GLSL fxShader string
    • tigrSetPostFX/tigrTime loop ("Shady" window). Needs size := HIGH(fxShader) (gm2 computes the literal's length - there is no LENGTH/strlen for ARRAY OF CHAR in ISO M2).
  • snake/snake.mod (new, no assets) - classic Snake on an 8px grid in the 320x240 window. Fixed-size body arrays (whole board = win condition), per-tick pending turn stored as INTEGER deltas (no 180 degree reversal), deterministic LCG food placement, tigrTime-deltas to pace movement, speed increases with score. Verified live: head advances one cell per step, eating grows it by one cell (forced food in the head's line), and a straight run ends in "GAME OVER" with the head frozen at the wall.
  • pong/pong.mod (new, no assets) - classic Pong in a 640x400 window. Two-player capable: W/S move the left paddle, UP/DOWN take over the right paddle from the CPU opponent. Ball and paddle positions are SHORTREAL, moved with tigrTime deltas; the ball speeds up (x1.08, capped) on every paddle hit and bounces off the paddle surface at an angle derived from the impact offset; first to 5 points wins. Verified live: serving, wall/paddle bounces, scoring and match restart all work.

GM2 gotchas encountered:

  • DEFINITION MODULE FOR "C" gl; puts FOR "C" before the module name (the order MODULE gl FOR "C"; only produces syntax warnings).
  • A FOR "C" definition without EXPORT UNQUALIFIED is mangled as <module>_<proc> (e.g. gl_glClear), so it cannot reach a plain C function like glClear; with EXPORT UNQUALIFIED glClear, ...; GM2 generates the plain C names and links straight against -lGL.
  • ISO Modula-2 has no CARDINAL/INTEGER bitwise AND/OR/SHIFT; use CAST(BITSET, n) * ... (intersection) / + (union).
  • There is no DOWNTO in ISO: write FOR k := n - 1 TO 1 BY -1 DO.
  • Adjacent string literals are not concatenated under ISO - keep strings on one line (done for the fxShader).
  • SHORTREAL literals (1.0) pass straight into C float parameters.
  • tigrTime() is not an absolute clock: each call returns the number of seconds since the previous call (i.e. a per-frame delta; the first call returns 0). Use it directly as dt := tigrTime(); per frame (as demo.mod and shader.mod do) - do not subtract two calls to build a "now" you then difference, or timers effectively never fire (seen in the first invaders version: dt = now - lastTime collapsed to ~0 and bombs never spawned).
  • GM2 16.0.1 (experimental, 2026-03-25) internal-compiler-errors (wide_int_to_tree_1 at tree.cc:1900, via fold_negate_const) when folding constant integer arithmetic: a compile-time a - b or a negated integer literal (e.g. Serve(-1), winH - 28, winW / 2 - 20) crashes cc1gm2. Workarounds used in pong.mod: keep CONST entries as plain literals, use precomputed literals at call sites (CenterPrint(372, ...)), and pass directions as BOOLEAN (TRUE = down/right, FALSE = up/left) instead of -1/1 INTEGER literals. Negating runtime variables (-bvy, -diff) is fine.
  • Real-typed comparisons need real literals: bvx > 0.0, not bvx > 0 (the latter is an ordinary ISO type error, SHORTREAL vs INTEGER, not a compiler bug).

Notes / minor oddities

  • tigr.def still declares stray global variables (Tigr : TigrType, TigrGlyph, TigrFont) that do not correspond to anything in the C library. They are not exported; they could be removed (TCodepage was removed along with BUG 3).
  • Essai1 uses tigrError to quit once the window is closed; on Linux tigrError prints to stderr and calls exit(1).

microui port (2026-09-16)

microui v2.02 (immediate-mode UI, rxi, MIT) was rewritten from the C in microui-master/ into ISO Modula-2:

file role
microui.def public API: types, constants, widget procedures
microui.mod full port of microui.c (widgets, layout, command list)
microuiHelpers.def/.mod bit ops (ISO has none), string/memory, number formatting
examples/microui-demo/microui-demo.mod interactive demo rendered with tigr
examples/microui-demo/microui-headless.mod off-screen smoke test writing microui-demo.png

Build:

cd modula2
gcc -c ../tigr-master/tigr.c -o tigr.o
gm2 -fiso -c helper.mod
gm2 -fiso -I. -c microuiHelpers.mod
gm2 -fiso -I. -c microui.mod
cd examples/microui-demo
gm2 -fiso -I ../../ ../../tigr.o ../../helper.o \
     ../../microuiHelpers.o ../../microui.o \
     microui-demo.mod -o microui-demo -lGL -lX11
gm2 -fiso -I ../../ ../../tigr.o ../../helper.o \
     ../../microuiHelpers.o ../../microui.o \
     microui-headless.mod -o microui-headless -lGL -lX11

The demo is rendered by tigr (not SDL): COMMAND_RECT -> tigrFillRect, COMMAND_TEXT -> tigrPrint with tfont, COMMAND_ICON -> line drawings, COMMAND_CLIP -> tigrClip. Mouse keys/buttons from tigrMouse, tigrKeyHeld/tigrKeyDown/tigrReadChar are translated into microui.input* calls.

Design adaptations

  • mu_Command is a C union; Modula-2 has none. It is represented by a BaseCommand header and the concrete command records; command buffers are walked with CAST(CommandPtr, ...) and each command is cast to its record.
  • C callbacks take several arguments, but GM2 rejects procedure types with more than one (unnamed) formal parameter (see G1 below). Each callback is therefore invoked through a one-argument record (TextWidthArgs, TextHeightArgs, FrameArgs).
  • drawText takes the string as ADDRESS + length (as in C) rather than an open ARRAY OF CHAR, so substrings can be passed without copying.
  • TCodepageType-style enums with non-consecutive ordinals do not apply here; all microui constants are plain CONST.

GM2 16.0.1 issues hit while porting

  • G1 - procedure types: when a DEFINITION MODULE is parsed as part of compiling its .mod, a procedure type only accepts a single, unnamed formal parameter. Both PROCEDURE(a : T; b : T) and PROCEDURE(ADDRESS; INTEGER) are rejected (the latter only in that context). Workaround: one type-only parameter per procedure type; bundle extra arguments in a record.
  • G2 - inline POINTER TO T: rejected in formal parameter lists and in CAST(...) (CAST(POINTER TO BYTE, p) fails). Declare a named pointer type alias (BytePtr = POINTER TO BYTE) and use it.
  • G3 - SHORTREAL(integer) is miscompiled: it silently yields 0. Conversion from INTEGER/CARDINAL to SHORTREAL must go through the standard FLOAT function and be assigned to a SHORTREAL (microuiHelpers.IntToReal / CardToReal). This silently broke the slider thumb position and every %.2f/%.3g display until found.
  • G4 - TRUNC and type aliases: TRUNC(x) where x's type is an alias (Real = SHORTREAL) gives "argument to TRUNC must be a float point type". Wrapping the expression in SHORTREAL(...) (or using a direct SHORTREAL local) works. ROUND is not defined at all in ISO mode.
  • G5 - EXIT is only allowed inside a LOOP, not in WHILE/REPEAT.
  • G6 - address arithmetic: ADDRESS does not support +; use ADDADR. ADDRESS(array) / CAST(P, ADDRESS(array)) fails; use ADR(array) (ADR must be imported FROM SYSTEM).
  • G7 - function results: f(x)^.field cannot be dereferenced directly; assign the result to a local first. Function return values also cannot be discarded - assign them to a dummy variable.
  • G8 - WHILE/REPEAT conditions mixing INTEGER and CARDINAL (e.g. i <= HIGH(a) with an INTEGER index) are rejected; convert HIGH with VAL(INTEGER, HIGH(a)).

Port bugs found and fixed

  • beginRootContainer pushed unclippedRect through pushClipRect, which intersects with the (empty) clip stack and produced a garbage clip rect; every draw was then clipped away (the first headless render was blank). It now pushes the unclipped rect directly, matching begin_root_container.
  • mu_update_control had the hover/no-interact logic inverted; rewritten to match the C.
  • checkbox/textbox/slider/number hashed the pointee value instead of the pointer; they now hash ADR(pointer) like the C.
  • RealToStr (added for the port) was rewritten to round correctly and to format %f / %g faithfully (fixed an off-by-one zero-pad and a lost minus sign in %g), and no longer relies on the broken SHORTREAL(int).
  • The tigr renderer used tigrFillRect alone to paint microui rectangles. tigr's tigrFillRect fills only the interior of a rectangle (it does x+=1; y+=1; w-=2; h-=2), so every rectangle was shrunk by one pixel and all 1px-high/wide rectangles (the control and window borders) disappeared - which showed up as the top of the window being "eaten" by the title bar. The fix is to draw tigrRect (1px outline) followed by tigrFillRect (interior): together they fill the exact rectangle, with clipping and alpha blending, and single-pixel rects still show.
  • The demo created its window with TIGR_2X, which forces a 2X scale (1600x1200 for the 800x600 bitmap). On a screen whose client area is shorter than that, tigr crops the bitmap, cutting off the top rows of the framebuffer - which is what "ate" the window title. TIGR_FIXED avoided the crop but letterboxes the fixed bitmap (resizing just recentres it and jumps between integer scales). The demo now uses TIGR_AUTO: tigr resizes the bitmap to match the window (1:1), so the UI draws on the full, growing canvas and resizing behaves like a normal resizable window. The renderer must use bmp^.w / bmp^.h (not the compile-time size) for its clip and clear.