The software renderer
How POD's WINPOD.EXE draws a race frame in software, traced for Un-POD's crates/pod-render (docs/PORT.md, "Renderer"). Addresses are POD's; the first edition's are in labels/pod/map.tsv. The tables, shade table, coverage mask, textured span routines, colour span drawers, horizon spans, polygon fillers, depth buckets, screen clipper, transform and projection are measured and ported. The horizon builder is measured too. The cloud span kernel span_cloud, its slice filler and the 16-bit single-view cloud layer are measured and ported. Both Un-POD renderers compose clouds under the horizon and world. apps/unpod places the sun marker during the cloud pass; pod-render draws the measured, dither-off RGB565 flare after the world only when the marker survives. The stand-in renderer's flare remains not ported.
The frame
The frame is 640 × 480 pixels of 16 bits: RGB 565, or RGB 555 when DAT_00621070 is 3 (observed: FUN_004965b4 packs the shade table by it). One row is DAT_006213ac bytes, 0x500 for a full-screen view (observed: FUN_00461e70 sets 0x280 or 0x500 by DAT_004eac84 in screen kind 5, and other values for the other kinds). The split screen doubles it while a view is drawn, so each view draws every other row (observed in FUN_0049af38: DAT_006213ac × 2 when DAT_00620fd0 & 2).
The renderer draws front to back. A one-bit-per-pixel coverage mask records what is drawn; spans that would land only on covered pixels are dropped, and at the end the pixels nothing covered are cleared to black (observed: clear_uncovered is called after the world, the cars and the sky; docs/hardware/mmx.md, "Coverage mask and clears").
One frame: FUN_0049af38
Called once a pass of the race loop (docs/systems/main-loop.md, step 9), under the semaphore SEM_AFFICHAGE (DAT_006a6de8, made by FUN_004a1460). For each view (one, or two in the split screen; the view records are 0xd4 bytes from 0x634b58) it calls, in this order (observed; the roles are inferred unless named):
| # | Call | What |
|---|---|---|
| 1 | FUN_0049b6e4 | copies the view's camera record into the renderer's globals (0x62c5a4.., 0x6218fc 12 dwords, 0x62192c 3, 0x62c5f8 12) |
| 2 | FUN_0048b800, FUN_00427bc0, FUN_0042798c, FUN_0049a9fc | per-view set-up (not traced) |
| 3 | FUN_004c6180, FUN_00414658(car), FUN_00427420, FUN_0045727c | the viewed car and the scene lists (not traced) |
| 4 | FUN_004267e4 for each car | the cars; the viewed car is left out in the cockpit view (+0x34 and +0x8c of the view record are 3) |
| 5 | FUN_0045d85c × 4 per human | (not traced) |
| 6 | FUN_00499424; FUN_004994cc for the first view | (not traced) |
| 7 | build_horizon FUN_00490544 when DAT_00621071 | the horizon's span lists, tested against the world's coverage ("The sky") |
| 8 | FUN_0048c8f4 when DAT_006218c8 | draw_cloud_layer (first edition FUN_0048fb50): the clouds' span lists ("The sky") |
| 9 | clear_uncovered FUN_004d5ba2, then fill_rect16 FUN_004d5c74 in the split screen | clears what nothing covered, by the screen kind DAT_00634d14 (4, 7 or 8) |
| 10 | draw_cloud_spans FUN_004950d4; draw_horizon FUN_00495804; FUN_004992c8; draw_sun_flare FUN_0048c248; FUN_0049ac2c | after the clear: clouds, horizon, depth buckets over both, sun flare and overlays ("The horizon"; the HUD is docs/systems/hud.md) |
The depth sort
Faces are not sorted as a list but put into 5,000 depth buckets (0x5abbbc; FUN_0048b848, observed). The face loop FUN_004580a4 gives each face a depth: for most faces the distance from the camera to the face's centre, the mean of its four vertices in camera space, through FUN_004d43ad (a square root through the table at [0x4ed434]); other faces use the mean z or the largest z of their vertices. A depth below 0 goes to bucket 0. Up to 100 units it gets z >> 12, a sixteenth of a unit per bucket. From 100 to 999 units it gets 0x640 + ((z − 0x640000) >> 16), and beyond that the face is dropped. Without the flag DAT_004f32e8 the bucket is 2,500 further on. A new face goes to the front of its bucket's list. FUN_00499424 fills the buckets from the nearest, each list in order; FUN_004992c8 draws them from the farthest, each list in order, skipping faces whose fill drew nothing (+0xc cleared). Before bucketing, FUN_0048b8d4 moves the projected x and y (vertex dwords 5 and 7) into dwords 0 and 1 and clips the polygon with FUN_00491b84 ("Clipping", below). pod_render::scene ports the buckets and both passes.
Fill near to far, draw far to near
The coverage mask drops only spans that are wholly hidden; a partly hidden span is drawn whole. So the frame fills its polygons nearest first, each through the mask, and draws the lists the other way round. In the recorded Beltane frame (derived/render/pod-beltane-filler/) all 84 fills come before the 59 span calls, and the span calls take the filled lists in reverse order, object by object: the objects reversed, each object's own lists kept in order (measured). 15 of the 74 lists the filler kept are not drawn by the dispatcher in that frame (open).
Transform and projection
How an object's vertices reach the face loop: in camera space and on the screen. Ported in pod_render::transform (camera, project, clip, faces, lights) and measured bit for bit under Unicorn (measure/render_transform.py). All positions, axes and camera-space values are 16.16 fixed point; screen values are whole pixels.
The object drawers
draw_object_lit FUN_0042663c and draw_object_flat FUN_00426720 (with draw_object_nocull FUN_00427268 and draw_object_group FUN_00427468) take the object in eax and the camera record in edx. In order (observed in the disassembly):
- Unless the object's kind (
+6) is 1 or 4, or0x739328is set,sphere_in_viewFUN_0045803cculls its bounding sphere: the object's position (+0x98) less the camera's, dotted bydot16FUN_004d41a0with each of the four plane normals at0x62c5f8(in the order+0x24,+0xc,+0x18,+0); the object is dropped when a dot is above its radius (+8). object_viewFUN_00457f18builds a 0x30-byte view on the stack: each camera axis turned into the object's space byto_localFUN_00424178(the object's matrix+0x40transposed, each product shifted down 16 bits, floor), and the translation, each camera axis dotted with (object position − camera position) bydot_roundFUN_004d6224. It also stores the translation at0x54334cand, throughFUN_004d5e28, as doubles at0x4ef2a8(not ported: nothing in the software path reads them, inferred).transform_objectFUN_00457db8(vertex count+0, vertices+0xc, 12 bytes each) runstransform_axisFUN_004d6263three times, z intocam_z0x556c50first, then x intocam_x0x5433d0, then y intocam_y0x54d010, and thenproject_verticesFUN_004d63dcintoscreen_x0x560890andscreen_y0x56a4d0.- The vertex lights into
0x621bc8(below). face_loop_trianglesFUN_00458cc8(the 0x20-byte faces at+0x14, not ported), thenface_loop_quadsFUN_004580a4(the 0x3c-byte faces at+0x18).ecxdiffers between the drawers (0 or 1) but the quad loop overwrites it before reading it.
The camera record is the view's, copied by FUN_0049b6e4 to chase_camera 0x6218fc: the position, then the camera's x, y and z axes in world space (+0xc, +0x18, +0x24) (observed). How the camera itself is placed each frame is the camera system's, not traced here.
Rounding through 1.5 × 2^68
transform_axis, dot_round and dot16 do their sums on the x87 and read the result out of a stored double. They add the products of 16.16 values (exact 64-bit integers) to the constant 1.5 × 2^68 (0x4ef290, 0x4ee0c0). The double's low dword is then the sum / 65536, rounded to nearest. The order is fixed: the constant (plus t × 65536 in transform_axis, added once per call) first, then r0·x, r1·y, r2·z, each product and each sum rounded to the precision control. At 0x27f (53 bits) this loses low bits of large sums; the port models it exactly with transform::x87::Sf (measured). transform_axis stores each double one dword before the vertex's slot, so the last store also writes a high dword into the entry past the end of each table; the port writes it too when the slice has room.
The projection's set-up
setup_race_projection FUN_00426558 calls clear_projection FUN_00457620, set_view_size FUN_0045779c (the size by the screen kind 0x634d14: 640 × 480 full screen), set_near_plane FUN_00457804 (near 0x14e20, 1.307 units) and set_projection_focalFUN_00457658 (focal 0xd8f0) (observed). set_projection_focal computes:
ys = focal × ((height << 16) / 0x1bd37)andxs = focal × ((width << 16) / 0x2c859), thenxs += xs / 5(integer divisions, truncated). For 640 × 480 both are0xe9e2c0, 233.886 pixels (measured).near × 2^29 / xsand/ ysat0x57411c,0x574120;near << 5at0x543344; the centre(height/2) << 16 | width/2at0x543348;height << 16 + widthat0x54337c.- Through
build_projection_tablesFUN_00457450: the centres0x543380/0x543388as doublessize × 2^15 + 1.5 × 2^68(half the size in 16.16 with the rounding constant); the reciprocal tablesrecip_x_table0x578f5candrecip_y_table0x579f5c, 1,024 floatsxs / 1024 / (1024 + k), andrecip_z_table0x57af5c,1 / ((1024 + k) × 1024), each with 0x49800000 added to its bits (0x93 on the exponent); and four step tables0x57412c,0x576844(0x641 entries: 0x7fffffff, then−((s << 16) / (k × 0x1000))) and0x575a30,0x578148(0x385 entries:−((s << 16) / (0x640000 + j × 0x10000))), for s = xs and ys. - The plane scales:
xs / near,ys / near(0x5433b0,0x5433b8), the same overface_cut_plane0x634d04(0x543390,0x543398) and overfar_plane0x634d0c(0x5433a0,0x5433a8). 1/near stays on the x87; 1/mid and 1/far are stored as doubles before the products (measured). The race sets the two planes tonear × 0x40andnear × 0x140(observed,FUN_00498ee4's caller).
Projecting a vertex
project_vertices FUN_004d63dc skips a vertex whose z is below the near plane (a signed compare against a value patched into its own code at 0x4d6400); its screen entries keep what they held. Otherwise z goes to a float; the top ten bits of its mantissa index the reciprocal tables, and z's exponent bits are subtracted from the entry's bits, which gives xs / z and ys / z as floats without a division. Then screen x is cx − x × (xs/z) and screen y cy − y × (ys/z), each product and difference rounded to the precision control, stored as a double and its low dword kept: the rounded pixel. So screen x falls as camera-space x grows, and screen y falls as camera-space y grows. Each store is a whole double one dword before the slot, so its high dword lands in the next vertex's entry and stays there when that vertex is skipped (measured). recip_z_table and the step tables are built but their readers are not traced.
The depth term the filler reads in vertex dword 4 is the vertex's camera-space z in 16.16 as the face loop copies it (measured: the loop stores cam_z there unchanged). Where it becomes the filler's depth term (after FUN_0048b8d4) is open.
The quad face loop
face_loop_quads FUN_004580a4 (object in eax, lights in edx, the camera position in ebx) first gets camera_in_object FUN_00457ffc: to_local(matrix, object − camera). Then for each 0x3c-byte face (observed, measured):
- It is skipped unless
+0x36bit 0 is set. The polygon flags start as+0x35 & 3. When+0x35 & 0x20,FUN_004a888cis called first (not ported; the measurement keeps the bit clear). - A face with no vertex at or nearer than
far_planeis dropped. The depth key: kind 2 takes the mean of the four vertices (each table summed, wrapping, then>> 2), its length byvector_length_x87FUN_004d43adwhen0x634dc8is set, else the mean z; other kinds take the largest z. - Wholly beyond
face_cut_plane: back-facing when the normal (+0) dotted bydot_roundwith (vertex 0 + the camera in object space) is above 0. Otherwise, a face with all four vertices at or nearer than the near plane is dropped; with none there it is front-facing when either screen triangle (0, 1, 2) or (0, 2, 3) has a non-negative cross product (32-bit wrapping); with some there the normal test decides (front when below 0). A back face is dropped unless two-sided (+0x35 & 4), which adds flag 8. - The vertices go into 8-dword records: x 0, y 1, u 2, v 3, z 4, screen x 5, light 6, screen y 7 (the far case writes only 5 and 7). Dwords the loop does not write keep the stack buffer's old contents, so a face can carry values from the one before; the port takes those buffers as input.
- Texture, colour and light: a textured face (
+0x34 & 0x40) has a record at+0x24(a page word, then a byte pair per vertex); vertex k gets u = byte 2k+1 << 7 and v = byte 2k << 7. If its detail bits (+0x36 & 0xc0) are aboveface_detail_level0x4ea891, the face is lit in one colour (flag 0x20), the texel at column byte 0, row byte 1 of its page (a byte in the 16-bit build0x634d17, else a dword); otherwise+0x34is ORed into the flags and the colour is the page index. An untextured face ORs+0x34and takes the colour+0x28. With flag 0x20, dword 6 of each vertex is its light from theedxtable. - A face wholly beyond
face_cut_planegoes tosubmit_polygonFUN_0048b8d4as it is. Others are cut byclip_zFUN_004579c8, first by the near plane (the far side kept), then byfar_plane(the near side kept), and the result, if it has more than two vertices, goes tosubmit_polygonwith flag 4 added.
clip_z walks the edges from the previous vertex to the current one. A vertex is on the far side when z ≥ the plane. A cut point is t = ((plane − a.z) << 16) / (b.z − a.z) (truncated), then x, y, z, u, v and light each a + ((b − a) × t >> 16) (floor), with a the near-side end, and its screen point cx − x × scale_x, cy − y × scale_y with the plane's scales (caller sets 0x5433c0/0x5433c8). Both halves are written, to the lists in ecx and ebx with counts at 0x574128 and 0x574124.
The vertex lights
draw_object_flat fills 0x621bc8 with flat_lights FUN_004264dc: every vertex gets the dword at 0x4f9974 plus 0x7100 (observed; ported, unit-tested). draw_object_lit calls choose_vertex_lights FUN_004265f0: kind 5 gets no new lights; kind 2 with +0xbc and +0xc0 set gets FUN_004262c8; every other kind FUN_00425ccc with ecx = 0x6332fc and the stack argument 0x633300 (observed). Both clamp each light to 0..0x3ffe, or in the 16-bit build halve it, clamp to 0..0x1fff and add 0x2000 for kind 2 or 0x4000 for kind 4. They sum the scene's light sources (from ebx + 0x28 count, +0x2c list) through FUN_004d4770; FUN_00425ccc also starts from a baked byte per vertex (+0xcc, << 7) or from transformed normals (FUN_004d465d, 0x4f99a8), and adds (z − ecx) × arg >> 16 beyond z = ecx (observed in outline, not ported).
Measured
measure/render_transform.py, 2026-10-25, control word 0x27f in two cases of three and 0x37f in the third; half the set-ups are the race's (640 × 480, near 0x14e20, focal 0xd8f0), the rest random sizes, planes and focal factors. crates/pod-render/tests/transform.rs replays them:
- set-up: 300 cases, every global, double and table entry the same;
- view, transform, projection and sphere cull: 3,000 objects, 61,543 vertices (47,008 projected, the rest behind the near plane), 2,768 spheres visible, every word the same, including the dwords written past the tables' ends;
clip_z: 3,000 polygons, 2,629 of them cut, both halves the same;- the quad face loop, with
submit_polygonreplaced by a recorder: 3,000 objects, 17,137 polygons (14,702 cut by the planes), every vertex, depth key, flag and colour the same, except that at 0x37f five kind-2 objects differ only in a distance key, becausepod_ai's port ofvector_length_x87is at 53-bit precision only. The game draws at 0x27f.
The shade bands
FUN_00498ad0 fills the shade table in bands of 64 rows, one per palette slot, each a ramp from the track's darkest to its brightest light (DAT_004f99b8, DAT_004f99bc, DAT_004f99c0, one per channel). Rows 0..63 take slot 0's palette, the track's main block; rows 64..127 slot 4's, CHROME.CH3's, for the cars; rows 128..191 slot 5's. Row 0xe0 is slot 3's palette at the middle light. Measured: rows 0..127 of the recorded frame's shade table are the same as ShadeTable::build_bands gives from BELTANE.BL3's and CHROME.CH3's palettes (crates/pod-render/examples/shade_bands.rs). A polygon's light therefore picks both its palette and its brightness. The rows FUN_00497a80 adds (192..254) are not ported.
Clipping
clip_polygon (FUN_00491b84) cuts a polygon to the view's rectangle before it is queued for the filler (observed in the disassembly; measured, below). Ghidra's pseudocode for it drops the register arguments (and the perspective cut's stack ones), so the port follows the disassembly.
- The caller.
FUN_0048b8d4copies each vertex's projected x and y (dwords 5 and 7) into dwords 0 and 1, calls the clipper with eax = the vertices, dl = their count and bl = the polygon's flag byte, and keeps the polygon when 3 or more vertices come back: it copies that many vertices (0x20 bytes each) from the same array into memory fromFUN_00427bd0and buckets it throughFUN_0048b848(observed). - The rectangle. Four globals, inclusive at both ends:
0x62138cx min,0x621390x max,0x621394y min,0x621398y max. Only the screen edges: there is no near plane here (observed). - Four passes in the Sutherland–Hodgman way, against y min, y max, x min and x max in that order. Each pass walks the vertices with the one before (the first with the last; pointers in
0x5e53f8,0x5e53fc, the next free slot in0x5e5400). A vertex on the plane is inside. Both inside: the vertex is copied (all 8 dwords). Coming in: the cut, then the vertex. Going out: the cut. Both outside: nothing. The passes write alternately into a scratch buffer at0x5db3b4and back into the caller's array, so the result ends in the caller's array. After any pass with fewer than 2 vertices the clipper returns 0. The counts are bytes. - The straight cut (without flag 4). The cut always runs from the outside vertex b to the inside vertex a: t = ((plane − b) << 16) / (a − b) by
idiv(toward zero), and each value is b + ((a − b) × t >> 16) with the 64-bit product. It sets x, y and the depth term (+0x10); u and v (+8, +0xc) only with flag 0x40 and the light (+0x18) only with flag 0x20. x is cut like y and not set to the plane. - The perspective cut (flag 4,
FUN_00491738; eax the new vertex, edx a, ebx b, ecx a's coordinate, then b's coordinate, the plane and the flag byte on the stack; the flag byte is not read). x and y are cut straight as above. The rest goes through the FPU with single-precision temporaries at0x61ea28..0x61ea40: t = (plane − b) × 1/(a − b) stored as a float, ra and rb the reciprocals of a's and b's depth terms, s = (ra − rb) × t + rb, q = 1/s (fld1,fdivrp), and the new depth term is q rounded. u, v and the light are ((a·ra − f(b·rb)) × t + f(b·rb)) × f(q), where f() is a store to a float, each rounded. All three are set whatever the flags. A value is rounded to an integer by adding 1.5 × 2^52 (0x59c00000) and storing the double, whose low dword is taken. - The reciprocals are not divisions: a float's top ten mantissa bits pick an entry of the table at
0x57af5cand its exponent bits are subtracted from the entry; the sign is dropped (for 1/(a − b) with a < b the clipper takes the reciprocal of b − a and flips the sign bit).FUN_00457450builds the table: 1/(i × 1024.0) for i = 1024..2047, stored as floats, then 0x49800000 added to each (observed; measured the same at both control words). - The clamp. At the end
FUN_00491b14clamps every vertex: y to y max, else to y min, then x to x max, else to x min (observed), so rounding cannot leave the rectangle. - What it leaves alone. A cut vertex keeps the slot's old dwords 5 and 7, and, in the straight cut, the old u, v or light without their flags. The slot is the scratch buffer's or the caller's array's, so those values are what an earlier polygon left there (measured: the test compares all 40 slots of the array and all 32 of the buffer). The scratch buffer has room for 32 vertices (0x404 bytes up to the reciprocal table at
0x5db7b8); the clipper does not check.
Measured (measure/render_clip.py, 2026-10-24): 10,000 random polygons of 0 to 6 vertices, inside, outside one edge, across the edges or anywhere, against the full screen or a random rectangle, with random flag bytes (flag 4 two times in three), at control words 0x27f and 0x37f, over a caller's array and a scratch buffer of random words. 6,880 came back with 3 or more vertices (up to 13), 4,540 of them cut in perspective. pod_render::clip gives the same count, array and scratch buffer in every case. The control word matters rarely: with the two swapped, 17 cases differ. The reciprocal table is the same as FUN_00457450's in all 1,024 entries.
pod_render::clip ports it: Clipper (the scratch buffer, kept between polygons), Rect, RecipTable, and the x87 arithmetic of the perspective cut in integers (clip/xf.rs).
Polygons into spans
fill_polygon_spans (FUN_004931ec) turns a polygon, already clipped, into one span record per row (observed in the disassembly; measured, below). Ghidra's pseudocode for it drops the register arguments, a call to FUN_004d7064 and one branch condition, so the port follows the disassembly.
- Input.
eaxpoints at the vertices andedxholds their count. Each vertex is 8 dwords: +0 x, +4 y, +8 u, +0xc v, +0x10 the depth term, +0x18 the light; +0x14 and +0x1c are not read.ebxholds the polygon flags: 8 swaps the sides, 0x20 fills the light, 0x40 the depth term and texture. Withecx= 0 the spans go through the coverage mask. The one stack argument receives the span list. - The screen.
FUN_00492e34first maps every vertex's x and y through two tables of 1,024 entries (0x6a4dd0,0x6a5dd0, indexed by the value& 0x3ff) and adds the view's offset (DAT_0062139c,DAT_006213a0). The split screen halves y. - The rows. From the smallest y (starting at 2,000) to the largest; none, nothing is drawn. Every edge with different ends runs from its upper vertex to its lower one. An edge going up fills one side of the records and an edge going down the other. The sides are the dwords given by
DAT_004eac64andDAT_0061ea20(4 and 0, swapped for flag 8). - First pass: x. Stepped in 16.16 with the reciprocal table: the step is
(Δ × (0x7fffffff / n >> 15)) >> 16. - The coverage test. With
ecx= 0 the records go throughcoverage_mark_spansfrom the mask atDAT_0061ea1c. When no span shows, the polygon is dropped and the arena is not advanced. - Second pass, per edge. The light (flag 0x20) is stepped with 10 bits of fraction and stored doubled. The depth term and the texture coordinates (flag 0x40) are equal, straight or in perspective by the edge's own depth ratio
far / (near >> 8). Up to 0xfe (and below 1, taken as 1) the edge goes through that row of the perspective table:FUN_004d7064walks the table from the nearer end and keeps each row's table value in the texture slot.FUN_004d6f20,FUN_004d6f86andFUN_004d6ff8then turn those values into u and v through the FPU, with the constants 1.5 × 2^68 (0x4f0af8) and 1.5 × 2^67 (0x4f0b00) that leave the rounded quotient in the stored double's low dword. Above 0xfe the depth term and the coordinates are straight. - Output. The list is the first row, the row count and the records, at
DAT_005f5410, which then moves past it. The texture slot holds u doubled in its low bits and v from bit 17.
Measured (measure/render_fill.py, 2026-10-24): 1,500 random polygons of 3 to 6 vertices, all flag combinations, the coverage test on and off, identity and uneven screen maps, at control words 0x27f and 0x37f. The port gives the same arena, mask and result in every case. 1,335 were drawn, 165 dropped, 967 textured and 613 went through the mask.
Measured against a frame of the game (race-loop session, derived/render/pod-beltane-filler/, Beltane about 21 seconds after GO!; crates/pod-render/examples/fill_replay.rs). The frame fills all its 84 polygons first and then draws the 59 span lists. Replayed in order from the frame's starting mask, 83 of the 84 return the same result. Every word the port writes into those 83 lists is the same as POD's (24,282 words). The last two (both flag 0x20) differ only through the mask: POD's had x = 307 covered on rows 35 to 46, ours did not. So something the log does not catch marked the mask between polygons 81 and 82 (inferred; a mask dump at every polygon is asked for).
Flat faces
fill_face (FUN_00499328) picks a face's filler from its kind, the bits 0xf0 of the face's word at +0xc (observed). Kinds 0x10 and 0x20 go to fill_polygon_spans, and so do kinds 0x40 and 0x60 when their flag 4 is set. Kinds 0x40 and 0x60 without flag 4 go to fill_fan_spans (FUN_0049493c). The face's flag 1 decides the coverage test: ecx is the flag, so a clear flag 1 means through the mask. The same flag later picks the drawer that leaves texel 0 transparent, so a see-through face does not mark the mask (observed). Despite the name of this section, these faces are not flat colour. They are textured and shaded. draw_face (FUN_00499080) hands their lists to draw_gradient_spans (FUN_00493070) with the texture page of the face's +8 (texture_page_of FUN_0049ca68) (observed). Flag 4 is what picks the full filler, with its perspective spans through draw_span_dispatch. Without flag 4 a face is drawn affine, one triangle at a time, with one set of steps per triangle.
The fan. fill_fan_spans takes the vertices in eax, their count in edx, the face's flags in ebx and the coverage switch in ecx, like fill_polygon_spans. It maps the vertices onto the screen first (FUN_00492e34). Its stack argument is the face: the routine writes the list's address over the face's +0 (observed). Then it reserves one dword for the triangle count and fills the triangles (0, k + 1, k + 2), in order of k. Flag 8 swaps the last two vertices of every triangle. It reads no other bit of ebx. It counts the triangles kept and stores the count in the reserved dword. With none kept, it moves the arena pointer DAT_005f5410 back and returns 0. A jump table (0x494924) has cases for 3 to 8 vertices. Any other count leaves the count's stack slot unwritten (observed; the port draws nothing for such a polygon, a Deviation:).
One triangle (fill_triangle_spans FUN_0049461c, the vertices in eax, edx and ebx):
- The rows run from the smallest y to the largest, h = ymax − ymin. With h ≤ 0 the triangle is dropped. The middle row is
ymin + (h >> 1). - The edges a→b, b→c and c→a are taken in that order. An edge with equal ends is skipped. An edge going up the screen is a right edge.
triangle_edge_right(FUN_004945a0) walks it from its upper end and writes x into word 4 of each row's record. An edge going down is a left edge.triangle_edge_left(FUN_00494498) writes x into word 0, and u, v and the light (vertex +8, +0xc, +0x18), each shifted left 9, into words 1 to 3. Both step in the same way as the first filler: 16.16 x, and the step(Δ × (0x7fffffff / n >> 15)) >> 16through the reciprocal table. - With
ecx= 0 the records go throughcoverage_mark_spans_at(FUN_004d5a60, from mask row ymin). The triangle is dropped when no span shows. That routine keeps the middle record's ends even when that span is hidden. - The steps across come from the middle row. Its width is w = right x − left x, and a negative w drops the triangle. This check comes after the marking, so the mask stays marked. The last right edge whose rows include the middle row (both ends counted) gives the value on the right:
frac = ((mid − top.y) << 16) / (bottom.y − top.y)byidiv, then shifted left 9. Each value is(Δ × frac) >> 16plus the upper end's value shifted left 9. The step is that value minus the left record's, times0x7fffffff / w >> 15, shifted right 16. The light gets a step only when w > 10, otherwise 0 (0x4947df). - Output. A header of five dwords: the first row, h, and the steps of u, v and the light. Then h records of 0x14 bytes: x of the first pixel, u, v, the light, and x one past the last pixel. The arena moves on by
(5h + 5) × 4bytes. A dropped triangle keeps its records in the arena and does not advance it.
Drawing (measured and ported). draw_gradient_spans (FUN_00493070) walks the triangle list and packs each header's u, v and light steps for the row drawer. It calls span_gradient (FUN_004d6af6), or span_gradient_keyed (FUN_004d6cee) when face flag 1 is set, for each row from the triangle's first frame row. The Rust port is pod_render::span::gradient; it receives the keyed choice from its caller and does not infer face kinds or source flags.
- The texel address is the texture page indexed by bits 16 to 23 of u as the high byte and those of v as the low byte. The shade row is light bits 16 to 23; this corresponds to the vertex light shifted right 7, as in the fan filler (inferred from the register packing at 0x4d6b79).
- The wrapper stores S0 = EAX & 0xfffeffff, S1 = ECX & 0xfffeffff, and the low 16 bits of EDX as S2. It derives D0 = 2·S0, D1 = 2·S1 plus the carry from doubling S0, and the low byte of D2 = S2 plus the carry from doubling S1. The two drawers use these carries differently.
- The ordinary drawer samples one texel per pixel until the first 8-byte boundary. Its aligned loop takes two texture samples; each shade-table dword fills two 16-bit pixels. Both dwords use the same shade row. Its end mask may write one final dword pair and one short pixel.
- The keyed drawer samples one texel per pixel throughout. Its aligned loop writes two independently keyed 16-bit pixels per 4-byte destination step, preserving each destination pixel when its texel is zero. The end mask may write one keyed dword pair and one keyed short.
Measured (measure/render_gradient.py, 2026-10-25; crates/pod-render/tests/gradient.rs): 240 POD 2.8 cases, 120 ordinary and 120 keyed, with 320- and 640-pixel rows, both 16-bit frame alignments, narrow and wide spans, carry-heavy and negative steps, multiple triangles, and zero/nonzero texels. The fixture pins target/pod/disc/WINPOD.EXE by SHA-256 and checks the drawer routine hashes. The Rust test compares complete frames byte for byte; the measurement also checks that the input lists and frame guards stay unchanged and that no writes escape the frame or the drawer's scratch globals. The keyed cases sampled 282 zero texels and 68,838 nonzero texels; two fixture generations were byte-identical. All 240 frames match. This is primitive-level evidence. The pod-render scene now connects the measured fan and row-span fillers to its polygon flags. It does not establish which roof faces use each path in a complete POD frame; the app's camera and face loop are still a stand-in.
These are the routines that draw the faces the dispatcher's empty kinds 0x40 and 0x41 (FUN_004d81b8, FUN_004d81b9) would have drawn. Faces of kinds 0x40 and 0x60 without flag 4 never reach draw_span_dispatch.
Measured (measure/render_flat.py, 2026-10-24; crates/pod-render/tests/flat.rs): 1,500 random polygons of 3 to 8 vertices, half convex (either winding) and half random points, some with every y equal. Flag 8 on and off, with random other flag bits. u, v and the light were small or full 32-bit, so the shifts wrap. The coverage test was on (625 cases, over random masks) and off, with identity and uneven screen maps, offsets and the split screen. 978 polygons were drawn, with 2,475 triangles kept, and 522 were dropped. In every case the port (pod_render::fill::flat) gives the same return value, list address, arena advance, every arena word written (records of dropped triangles included) and mask. Two deliberate faults were tried in the port: the light threshold made w ≥ 10 failed 29 cases, and ignoring flag 8 failed 698.
Flat-colour and Gouraud drawers (draw_flat_spans FUN_00492f9c, draw_shade_spansFUN_00492fec) are measured through their wrappers on 700 synthetic cases (measure/render_color_faces.py, 2026-10-25). The fixture covers 320- and 640-pixel widths, both frame alignments, one- to three-pixel and full-width rows, reversed and hidden rows, 16-bit pixel patterns, colour arguments above 0xffff, and 16-bit or overflowing vertex lights. Full frame bytes, wrapper registers and shade routine globals match POD in all cases. Two full runs produced identical fixtures; four planted model errors were caught. The flat and ordinary Gouraud drawers are ported in pod_render::span::color; apps/unpod/src/render_pod.rs now routes source FLAT and GOURAUD faces to them, while textured faces keep the scene path. The default stand-in renderer still shades page-less faces gray.
span_solidFUN_004d4e60skips rows when x1 is less than or equal to x0, using unsigned comparison. With odd x0 it writes one extra pixel at x1. Each pair is stored as a dword; the high pixel includes the upper half of the colour argument, so values above 0xffff can produce different adjacent pixels.span_checkerFUN_004d4ea8draws zero where absolute x plus screen row is even. For a reversed row it starts at x1 and draws toward x0.span_shadeFUN_004d4f00adds its light step before each shade-table lookup. Aligned pairs of pixels use one dword from the table; a covered row whose end was cleared to zero still writes one pixel at x0. Its negative-width step reads before the reciprocal table. The fixture uses both zeroed and synthetic bytes there, because the live renderer state in that region has not been identified; the shade of that one pixel in a real frame remains unknown. Solid fill with a row count of zero or less is not measured because its loop would run 2^32 rows.
Spans
A span record is 0x20 bytes, one per screen row (observed in FUN_004d7120):
| Offset | Size | What |
|---|---|---|
| +0x00 | i32 | x of the first pixel |
| +0x04, +0x06 | u16, u16 | u and v at the start, 8.8 fixed point (the integer parts address the 256 × 256 texture page) |
| +0x08 | u32 | the light at the start, 8.8 (the integer part is the shade row) |
| +0x0c | u32 | the depth term at the start (larger is nearer: the perspective uses the ratio of the two ends) |
| +0x10 | i32 | x one past the last pixel |
| +0x14, +0x16, +0x18, +0x1c | the same at the end |
draw_span_dispatch picks the routine by ebx & 99 and stores the texture page's address (edx) in DAT_005f5408; the page is 64 KiB aligned, so the texel's address is the page's top half with u and v as its low bytes (observed):
| Kind | Routine | What (measured) |
|---|---|---|
| 0x60 | span_textured_60 FUN_004d7120 | textured and shaded |
| 0x61 | span_textured_61 FUN_004d7c3d | the same with texel 0 transparent |
| 0x40, 0x41 | FUN_004d81b8, FUN_004d81b9 | nothing (ret) |
Each routine takes ecx spans at esi into the frame at edi, with eax 0 to force every span affine. A whole polygon is drawn affine when the depth ratio of its middle span is 0xfa/256 or more. A single span is drawn in perspective when it is at least 16 pixels long, its ends' depth ratio min × 256 / max is at most 0xfa, and either the nearer end's depth term is at most 0x12c00 or the span is at least 0x2a pixels long; otherwise it is affine.
Affine spans step u, v and the light in fixed point, carried between registers with adc. Perspective spans take the ratio as the row of the perspective table and the position along the span (16 bits of fraction) as its column.
Both kinds draw one texel per pixel up to the first 8-byte boundary of the row. After it each texel covers two pixels, so the image is drawn at half resolution across. A perspective span takes a sample every four pixels: the first pair of pixels shows the sample, and the second pair shows the midpoint of it and the next sample. The span's last pixels come in pairs and singles, as the end address allows. The stores are aligned to the frame's own address, so the frame must be 8-byte aligned (a DirectDraw surface is).
The x87 trick
Kind 0x60's perspective path packs u (bits 0..23), v (from bit 24) and the light (from bit 48, with bit 60 set) into one 64-bit integer, so every value has the same exponent. It loads it with fild and computes start + delta × table / 256 with fmul and fadd, then stores the result as a double. The double's byte 0 is then u's integer part, byte 3 v's, and its high dword, shifted, indexes the shade table directly: [edx × 4 − 0x1088b234] wraps round to 0x634dcc + (row × 256 + texel) × 4. The midpoint is the sum of two stored samples. The sum has one more bit of exponent, so its bytes are half the sum, and the light row stays the first sample's. Kind 0x61 writes u and v into the low bytes of the doubles 1.0 at 0x4ef340 and 0x4ef348 and scales the deltas by 2^-60 (0x4ef338). Its light comes from the position register, as in the affine path (observed; measured through the port).
The FPU's precision changes the result. fmul and fadd round to the precision control, then fst rounds to 53 bits. With control word 0x37f (64-bit precision, what Watcom's start-up fninit at 0x4c9646 leaves) against 0x27f (53-bit, what Windows gives a new thread), 2,025 of 4,000 random span lists come out different (measured). POD runs its game on a thread it makes with CreateThread (FUN_004a0e94), and that thread draws with 0x27f (measured: the control word at all 55 entries of FUN_004d7120 in a recorded Beltane frame, below). pod-render's fpu::Precision defaults to it.
The horizon
After clear_uncovered, draw_frame_views draws two passes of its own before the depth buckets' spans (FUN_004992c8), so the world lands on top of both (observed at 0x49b339..0x49b37d):
| Pass | When | Lists | Page | Span routine |
|---|---|---|---|---|
draw_cloud_spans FUN_004950d4 | DAT_006218c8 (the sky is on) | DAT_005dada4, made by draw_cloud_layer FUN_0048c8f4 this frame | [DAT_0061ffe8 + DAT_006218c4 × 4 + 0xc] | span_cloud FUN_004d6900 |
draw_horizon FUN_00495804 | DAT_00621071 (the BkGround byte) | DAT_005dafac, made by build_horizon FUN_00490544 this frame | [DAT_0061ffe8 + DAT_006213b4 × 4 + 0xc] | span_horizon FUN_004d81ba |
Neither goes through draw_span_dispatch, which is why the recorded frame has neither.
What the horizon is (observed). DAT_00621071 is the u32 the track file gives after the sky's first four values (docs/formats/tracks.md, "The sky"; stored by load_track_fileFUN_0046a1e8, which also keeps it in bits 8..15 of DAT_006218cc). When it is set and DAT_0063331c & 3 is 0, race_session FUN_0049bd8c reads it again from the profile file named at 0x58ddb0 (GetPrivateProfileIntA, section POD2_0, key BkGround, the track's value as default), so the player can turn it off; when DAT_0063331c & 3 is not 0, FUN_00497760 clears it (inferred: those bits mark the split screen or a network game). The page is the horizon block's: at track load find_horizon_page FUN_004915bc compares the block's name at 0x62cd28 with NEANT (through str_icmp FUN_004c805c) and keeps its first page in DAT_006213b4 (find_texture_page FUN_0049d01c). That block is the 1,024 × 64 panorama kept column by column, and the span routine addresses it that way (below). In the recorded Beltane frame DAT_00621071 is 1 (measured from the frame log's copy of 0x621070).
The lists. build_horizon (4,214 bytes; read for its outline only) builds a ring of 8 segments round the camera between the heights DAT_006218f4 and DAT_006218f8 (the sky's "98" and "9c" values), clips each quad at both heights (FUN_004915ec, inferred from its arguments) and projects it. Each vertex gets x and y on the screen (clamped to the view), u from the angle round the ring (through FUN_004c928e or FUN_004c92d8 by quadrant, then fpu_chop, kept to 17 bits), v from the height, (h − low) × 0x1f80 / (high − low), and the light 0x2000. A segment that crosses the seam of the panorama (u past 0x18000 one end, under 0x8000 the other) is split in two (inferred from the tests on the previous vertex's u). Each polygon goes to fill_horizon_spans FUN_004951ec, its own filler: the screen map FUN_00492e34, then coverage_test_spans, which only tests the mask (after the clear it is empty, so every span shows). The block it leaves is a u32 list count, then per list the first row, the span count and that many 0x20-byte span records, the filler's usual format (inferred from what draw_horizon reads). Its output is measured end to end below.
draw_horizon (observed, measured) takes the block in eax and the page in edx. For each list it sets edi to DAT_00621bb0 + row × DAT_006213ac, esi to the spans and ecx to their count, stores the page in DAT_005f5408 and calls span_horizon; the next list starts count × 0x20 + 8 bytes on.
span_horizon (401 bytes; observed in the disassembly, measured). It reads only x, u and v of each record (+0, +4, +6, +0x10, +0x14, +0x16): no light, no depth term, no FPU, no perspective. Against span_textured_60:
- A row with x0 ≥ x1 (signed) draws nothing.
- The steps are
(end − start) << 9times the reciprocal0x7fffffff / n, the high dword: u's in 8.16, cut to 8.8; v's integer part masked to 10 bits and its fraction kept to 16 bits. The position register holds u in its low word and v's fraction in its top byte, with bits 16..23 as v's lower fraction, so a carry out of u runs into v. v's integer part is inbh. - The texel: the page's top half, then u's integer part as the high byte and
(u's fraction & 0xc0) | v's integer partas the low byte. So the page is read as 1,024 columns of 64 texels, u in 8.2 and v in 6 bits. v's integer part is masked to 6 bits only at the span's start; the steps add to it unmasked, so a v past 63 spills into u's fraction bits (the port keeps this; 2,553 of the 4,000 cases change without it). - The pixel: always shade row 0xe0 (
0xe000 | texel, set at 0x4d82a4), slot 3's palette at the middle light ("The shade bands"). Texel 0 is not drawn. - The order: one pixel when the start is not on a 4-byte boundary (its texel, then the step), then one texel per two pixels with doubled steps, one dword store each, up to the end's 4-byte boundary, then one last pixel when the end is not on it, from the texel at the position reached, with no step. The span is thus at half resolution across, like the others, but aligned to 4 bytes rather than 8.
Measured (measure/render_horizon.py, 2026-10-24): 4,000 random span lists of 1 to 6 rows (x from 0 to 640, a few with x0 ≥ x1, half with v in the horizon's range and half with any 16-bit u and v, a random shade table and a page with 30 % zero texels), and 400 random blocks of 0 to 4 lists through draw_horizon itself. pod_render::span::horizon::horizon and pod_render::horizon::draw_horizon give the same frame in every case.
draw_cloud_spans (observed, measured, and ported). Its block is a u32 group count; per group a u32 triangle count; per triangle the five-dword header of the triangle filler (first row, row count n, u step, v step, light step) and n records of 0x14 bytes. Each triangle goes to span_cloud FUN_004d6900. Then, when the sun is on screen (DAT_005dad88) and enabled (DAT_006218d8, bit 4 of DAT_006218c8, set by cloud_setup FUN_0048dc58), it calls place_sun_marker FUN_0048c100 with the sun's x and y (DAT_005dad9c, DAT_005dada0). That writes the bytes 0x12, 0x34 into the frame at the sun's pixel. After the world's spans, draw_sun_flare FUN_0048c248 draws only when those two bytes are still there, that is when nothing in the world covered the sun (observed test at its start; what it draws is in "The sun" below). The register packing and the span routine are in "The clouds" below.
The tables
Built once by FUN_00495b14 (measured: the port gives the same bytes under Unicorn at both precisions; Unicorn's own start-up control word 0, 24-bit precision, gives a different perspective table, so the harness sets 0x37f first):
| Table | Address | Contents |
|---|---|---|
| Perspective | 0x5f5414 | 256 rows × 256 words. Row r (1 to 254): k = (256/r − 1)/256 as a float, then for column j t = 1/(j·k + 1) as a float and (t − 1) · 256 / (r/256 − 1), clamped to 0..255 and chopped. Row 255 is j itself, row 0 zero |
| Reciprocal | 0x5db7b8 | 0x7fffffff / n for n from 1 to 9,999; entry 0 is 0 |
| Multiply | 0x5e5408 | 256 × 256 bytes, (j × r) >> 8 |
The chop is FUN_004c76a0, a frndint with the rounding control set to chop.
The shade table
0x634dcc: 257 rows of 256 dwords. Each dword holds a 16-bit pixel twice, so a span writes two pixels with one store. FUN_004965b4 (palette in eax, row in edx, red scale in ebx, green in ecx, blue on the stack, Watcom's register order) builds one row from the 768-byte palette. A scale under 0x80 darkens a channel (c × k >> 7); from 0x80 up it moves the channel toward white (c + ((255 − c) × (k − 0x80) >> 7)). The row is then packed to 565 or 555. Row 0xca also finds the palette entry nearest pure blue, and row 10 the one nearest the colour DAT_006218d0 (observed; the shade rows are measured, 800 cases). FUN_00497a80 calls it at track load: row 0x80, then rows in steps from 0x8c and 0x77, then 32 rows by DAT_006218ec's sign (observed; the exact arguments are not yet read).
Palettes and texture blocks
The 16-bit software build (DAT_00634d17 set) keeps eight palette slots. Slot k's palette is at 0x61eae0 + k × 0x301 and its flag at 0x61ede0 + k × 0x301. load_texture_block_mode (FUN_00465774) loads each texture block into the slot its caller passes in bl (observed):
| Slot | Loaded by | Palette |
|---|---|---|
| 1 | load_track_file (FUN_0046a1e8, 0x46a598): the block at 0x62cc28 | 0x61ede1 |
| 2 | the environment's objects (0x469951, 0x469ad7) | 0x61f0e2 |
| 3 | load_track_file (0x46a4b3): the track's textures | 0x61f3e3 |
| 4 | load_chrome_file (FUN_004a8750, 0x4a8851), then every car (load_car_parts FUN_0046aabc, 0x46ac87) | 0x61f6e4 |
The flag decides (0x465984). The first block in a slot copies its palette into the slot (0x465bc4). Every later block's pages are rewritten to the slot's palette, byte by byte, through a map from FUN_0049d2cc. In that map index 0 stays 0, and every other index goes to the nearest slot colour by squared RGB distance (FUN_0049d240). The search runs over entries 1 to 255, or 0 to 255 in slot 0, and the first of equals wins (measured: measure/render_remap.py, 500 random palettes, the port gives the same maps).
The race load (FUN_00497d70) loads CHROME.CH3 before the cars. So the cars' palette is the chrome's. The eight cars of the first edition (ARC, AV1, BAKAR, DOPPLE, GAMMA, SABER, SCORP, SHIVAN) already use it, and their maps are close to the identity. The cars POD added have palettes of their own and are remapped to it (measured: each .BV3 page put through its own palette matches the .BV4 pages to within the 565 rounding; the eight share one palette, which is also CHROME.CH3's). The shade rows of FUN_00497a80 come from slot 4's palette, and those of FUN_00498ad0 from slots 4 and 3.
Every .BV3 carries its car's palette in the block's palette slot, in both editions (measured). load_car_file (FUN_0046ae1c) takes the file by its mode in dl: 0 .BV3, 1 .BV5, 2 .BV4 (0x46ae76..0x46b0f6). The race load passes 0 in the 16-bit software build (observed at 0x497d70..0x497d86).
The coverage mask
0x50 bytes a row, pixel x is bit 31 − x mod 32 of dword x / 32. The masks of 0x4ee858 have their top n bits set for n from 0 to 639. The dword after the table, which a span the full 640 pixels wide reads, is 0 in the binary, so such a span marks nothing in its first 32 pixels (observed). All four routines are measured against the port (800 cases):
| Routine | What |
|---|---|
coverage_mark_spans FUN_004d59e0 | mark 0x20-byte spans, one per row; a span that adds nothing gets its end set to 0; returns non-zero when any span showed |
coverage_mark_spans_at FUN_004d5a60 | the same for 0x14-byte records; the middle record keeps its ends |
coverage_test_spans FUN_004d5b2c | test without marking; it steps the mask pointer a row before reading, so span i is tested against row i + 1 |
clear_uncovered FUN_004d5ba2 | black for every 8 pixels whose mask byte has a clear bit, then reset the mask |
fill_rect16 FUN_004d5c74 | black over a rectangle, 64 pixels at a time (observed) |
The sky
The sky is two layers behind the world: the horizon, a 1,024 × 64 panorama on a ring round the camera, and the cloud layer, a textured plane above it. Neither goes through draw_span_dispatch.
Order (observed in draw_frame_views, steps 7 to 10 above). Both layers build their span lists before clear_uncovered, after the world's and the cars' fills, so they are tested against the coverage mask the world left: a sky span wholly hidden behind the world is dropped. The horizon only tests the mask. The clouds also mark it (fill_cloud_triangle calls coverage_mark_spans_at, below), after the horizon has built its lists. After the clear they are drawn first, clouds then horizon, and the depth buckets' spans (FUN_004992c8) land on top of them. So the clouds lie under the horizon's opaque texels, and both under the world. clear_uncovered blacks only what the world left open; the sky is drawn over that black.
| Layer | When | Builds | Draws | Page | Span routine |
|---|---|---|---|---|---|
| horizon | DAT_00621071 (the BkGround setting) | build_horizon FUN_00490544 into DAT_005dafac | draw_horizon FUN_00495804 | [DAT_0061ffe8 + DAT_006213b4 × 4 + 0xc] | span_horizon FUN_004d81ba |
| clouds | DAT_006218c8 (the cloud flags) | draw_cloud_layer FUN_0048c8f4 into DAT_005dada4, with FUN_00494ee8 | draw_cloud_spans FUN_004950d4 | [DAT_0061ffe8 + DAT_006218c4 × 4 + 0xc] | span_cloud FUN_004d6900 |
The page lookup, pass order and BkGround setting are traced. The horizon builder and its filler are measured below; the exact horizon pass and span routine are described in the next subsection.
The horizon's span lists: build_horizon FUN_00490544
Observed in the disassembly and measured (below). eax is the camera record (0x6218fc layout, 12 dwords, copied from the view by FUN_0049b6e4): the position (+0, not read), then three 16.16 unit vectors, the screen's x axis (+0xc, pointing left: a direction along it goes to smaller x), its y axis (+0x18) and the view direction (+0x24). It reads the screen size DAT_00621220/24, the projection divisors DAT_00574110/14, the clip rectangle DAT_0062138c..98, the projected y range DAT_0061ea50/54 and the band DAT_006218f4, DAT_006218f8: the track's first two cloud words, the panorama's lowest and highest elevation as the z of a unit direction (Beltane: 9,100 and 29,900, about 8° to 27°).
- The view's extent. The half extents are the x axis times
(width × 0x85dc) / focal_xand the y axis times(height × 0x85dc) / focal_y(16.16 divisions); the corner forward + x − y is normalised and its two ratios (x part and y part over the forward part) give the screen scales((xmax − xmin) << 15) / ratioand−((ymax' − ymin') << 15) / ratioover the projected range. The screen centre is the clip rectangle's middle in x and the projected range's in y. - Eight strips. From +x to −x in steps of a quarter of the half extent: each strip is the quad forward + c ± y, forward + c' ± y, each corner normalised (
vector_normalizeFUN_00423bc0). - The band. Two Sutherland–Hodgman passes clip the quad to z ≥ low, then z ≤ high. A crossing is found by
horizon_clip_edgeFUN_004915ec: five halvings of the arc between the two directions (each midpoint the normalised sum of the ends), the last midpoint normalised again, and its z set to the plane. The linear step the routine computes after is overwritten (0x4916c8). A polygon of 2 points or fewer is dropped. - Projection. Each point: x = centre − (dot(p, x axis) / dot(p, forward)) × scale >> 16, y likewise; v = (z − low) × 0x3f × 128 / (high − low) (the panorama's 64 rows in 1/128); the light 0x2000. Then x and y are clamped to the clip rectangle.
- The panorama's column. The point's horizontal direction is normalised (x clamped to ±1) and turned into an angle by quadrant, chosen once per strip from its first point: y < −0x7fff: 2π − acos x; y > 0x7fff: acos x; otherwise x ≥ 0: asin y + 2π, else π − asin y (
acos_x87FUN_004c928e,asin_x87FUN_004c92d8). The angle is stored as a float, then u = trunc((a + π) × 128 × 1024 / π), and the column is((u + 0x20000) >> 1) + 0x200masked to 17 bits: 1,024 columns round in 1/128 column. The constants are the doubles at 0x4e730c (π), 0x4e7314 (2π) and 0x4e7304 (1/π), the compiler's from the decimal 3.1415927 and its double. So the panorama turns with the camera's heading (a quarter turn moves it 256 columns) and the band's height on screen follows the pitch. - The seam. A strip that crosses u = 0 / 0x1ffff is cut in two (0x490f83..0x4911ca): a point with u ≥ 0x10000 goes to the first polygon, else to the second; an edge across the seam adds its crossing (v, x, y interpolated and chopped,
fpu_chopFUN_004c76a0) to both, with u 0x1ffff in the first and 0 in the second. - The filler
fill_horizon_spansFUN_004951ec(its own, notFUN_004931ec): the screen mapmap_verticesFUN_00492e34, one 0x20-byte record a row (x at +0 and +0x10, the texture dword at +4 and +0x14), x stepped in 16.16 by0x5db7b8's reciprocals, thencoverage_test_spansagainst the mask; a polygon with no span showing is not kept. Only then the texture dwords: the low half u / 4 doubled (so the low word's high byte is the page column / 4 and bits 6..7 the column's quarter), the high half v from bit 17 (v / 128 the row). Each kept polygon is one list (first row, row count, records); the block starts with the list count.
The page. span_horizon reads the texel at the page's high byte = u's integer byte and low byte = u's top two fraction bits over v's row: the 256 × 256 page holds the panorama column by column, 64 bytes a column, as the .BL3 file stores it (docs/formats/tracks.md, "The sky"). Every texel goes through shade row 0xe0, which FUN_00498ad0 builds from slot 3's palette at the middle light; slot 3's palette is the horizon block's own (measured: row 0xe0 built from Beltane's horizon palette with the default light range is the recorded frame's row 0xe0, --example sky). Texel 0 is not drawn.
The full-screen view's clip rectangle is 0..0x27f by 0..0x1df (set_view_clip FUN_0049751c, observed); spans end before their end x, so the horizon leaves column 639 to the clear (inferred from the port, which matches the builder). The projection's divisors come from projection_setup FUN_00457658 with the zoom 0xd8f0 (FUN_00426558 at 0x42658c): focal_y = zoom × ((height << 16) / 0x1bd37), focal_x = zoom × ((width << 16) / 0x2c859) plus a fifth (observed; not measured).
The clouds: draw_cloud_layer FUN_0048c8f4
POD 2.8's WINPOD.EXE. Traced in the disassembly of target/pod/disc/WINPOD.EXE (the export's pseudocode drops the register arguments and removes the sun block as unreachable, so this follows the instructions). The roles of the values are observed unless marked. The cloud layer, the clip edge and the draw pass are measured under Unicorn and ported for the 16-bit build (pod_render::sky::cloud, "Ported: the cloud layer" below). The app calls this pass from apps/unpod/src/sky.rs for the indexed .BL3 page; its chase camera and empty coverage mask remain stand-ins. The per-track values are measured by decoding POD's 33 .BL3 files with decoders/track.py.
The track's values. load_track_file reads eight u32 after the horizon block, in file order into DAT_006218f4, f8, c8, c0, e0, ec, f0, e8 (0x46a4d3..0x46a54f). Then it loads the cloud block named at 0x62cc28 into palette slot 1 (0x46a598). The u32 after that block goes to DAT_006218d0.
| Global | Meaning | POD's tracks |
|---|---|---|
DAT_006218f4, f8 | the horizon's band ("The horizon") | |
DAT_006218c8 | cloud flags: non-zero draws the layer; bit 4 (0x10) is the dome-and-sun mode; no other bit is tested here | 0 on COCOON, MINE and PIPELINE; 0x11 on BABELWEB, CANYON, DOWNTOWN and VEITLAND; 0x3 GALLERIA; 0x13 TEST; 0x1 on the other 24 |
DAT_006218c0 | the plane's height, world z in 16.16 | 0x2bde780 to 0x83e0000 |
DAT_006218e0 | the right shift from world position to texture coordinate | 13, TEST 10 |
DAT_006218ec | the distance fade's slope, clamped to ±7,000 | −7,000 to 9,000 (DOWNTOWN's 9,000 is clamped) |
DAT_006218f0 | the distance where the fade starts, in the units of vertex +0x10 × 8 | −600 to 64,000 |
DAT_006218e8 | the scroll added to DAT_006218e4 every frame the layer is drawn | 0 to 200 |
The player's setting. race_session FUN_0049bd8c reads Sky from section POD2_0 of the profile at 0x58ddb0 with GetPrivateProfileIntA. It does this only when the track's flags (the low byte of DAT_006218cc) are not 0, and passes them as the default. A non-zero answer keeps the track's flags in DAT_006218c8. Zero clears them, which turns the clouds off. The setting is written back with WritePrivateProfileStringA (0x49c1ba). So the player can switch the layer off but cannot change its mode.
Set-up at track load. track_render_setup FUN_00497a80 calls cloud_setupFUN_0048dc58, which:
- sets the scroll
DAT_006218e4to 0 and clampsDAT_006218ecto ±7,000; - sets
DAT_006218d8to 1 when bit 4 ofDAT_006218c8is set, and leaves it at 0 otherwise; - sets the cloud page
DAT_006218c4=find_texture_page(eax0x62cc28, edx 0, ebx 1), the block's first page; - sets the sun box
DAT_005dad70=DAT_005dad74to 0x96, or 0xdc when the screen widthDAT_00621220is over 400; - sets the light bounds. In the 16-bit build (
DAT_00634d17set) the upper boundDAT_005dad78is 0x6f80, or 0x6080 with bit 4, and the lower boundDAT_005dad7cis 0x6080. The 8-bit build uses 0x4000 and 0x80, or 0x2000 for both with bit 4.
In the 16-bit build track_render_setup then builds the cloud shade rows from slot 1's palette (FUN_004965b4, eax 0x61ede1). When DAT_006218ec > 0, row 0xc0 + i (i = 0..31) uses the scale 0x80 + 4i on all three channels, fading toward white. Otherwise it uses 0x80 − 4i, fading toward black, and negates DAT_006218ec (0x497c7d..0x497cb1). So at draw time the slope is never negative, and the sign the track gave only picks the fade colour. The routine then builds row 0xe1 from slot 2 at 0x80. load_track_select FUN_00497760 clears DAT_006218d8 together with the BkGround byte (0x4978ca; inferred: split screen or network).
When it runs. draw_frame_views calls draw_cloud_layer (eax = the camera record 0x6218fc) when DAT_006218c8 is not 0, after build_horizon and before clear_uncovered (0x49b27d). The routine:
Takes the arena pointer
DAT_005f5410as the block's start, keeps it inDAT_005dada4and reserves the group count dword. It clampsDAT_006218ecto ±7,000 again.Height test. It returns at once unless the camera's z (record +8) ≤
DAT_006218c0and the difference h =DAT_006218c0− z ≤ 0x9ffffff. On this early return the reserved count dword is not written andDAT_005dad88is not cleared (0x48c956, 0x48c976 jump past both).Stores the byte
DAT_006213b9inDAT_006218dc, or 0xff when the screen kindDAT_00634d14≥ 3. Nothing else in the code refers toDAT_006218dc(a byte search of the code section finds only these two stores).Adds
DAT_006218e8to the scrollDAT_006218e4.With bit 4, computes the dome scale K = div16(0x4000, div16(fixed_sqrt(0x10000 − mul16(0x2ee0, 0x2ee0)), 0x2ee0)). That is 0.25 × z0 / √(1 − z0²) with z0 = 12,000 / 65,536, so a direction at the clip elevation lands a quarter page from the centre.
The view's frame, the same set-up as
build_horizonstep 1: xs = the x axis × ((width × 0x85dc) /DAT_00574110), ys = the y axis × ((height × 0x85dc) /DAT_00574114) (vector_scale_into). The corner forward + xs − ys is normalised and gives the ratios div16(c·xs, c·fwd) and div16(c·ys, c·fwd). From them come sx = div16((DAT_00621390−DAT_0062138c) << 15, ratio x) and sy = −div16((DAT_0061ea54−DAT_0061ea50) << 15, ratio y). The centre is cx = (x min + x max) / 2 and cy = (DAT_0061ea50+DAT_0061ea54) / 2, both divided rounding toward 0.The sun. It clears
DAT_005dad88. When dot(0x4f99ac, forward) < −10,000, it setsDAT_005dad88= 1,DAT_005dad9c= cx − (mul16(div16(L·xs, L·fwd), sx) >> 16) andDAT_005dada0= cy − (mul16(div16(L·ys, L·fwd), sy) >> 16), with no clamp (0x48d5f2..0x48d699).0x4f99acis the track's global light direction, read and normalised byread_track_lightingFUN_00469f24(0x469f5c, 0x469f72). Its z is negative on every POD track, so the light points down and the sun lies along −L. This runs whatever the flags.Seven strips. The step is xs × (−2.0) × (1/7) (
vector_scale_intowith 0xfffe0000, thenvector_scalewith div16(0x10000, 0x70000)). Starting from c = xs, each strip moves c on by the step and makes the quad (previous top, new top, new bottom, previous bottom). Top is forward + c + ys and bottom is forward + c − ys, each normalised. These are world-space unit directions, and z is their elevation.The elevation clip (Sutherland–Hodgman, one plane, starting with the edge from vertex 3 to vertex 0). A direction is inside when its z > 12,000 (0x2ee0, about 10.5°):
- inside to inside adds the current vertex;
- outside to inside adds the crossing, then the current vertex;
- inside to outside adds the crossing;
- outside to outside adds nothing.
The crossing is
cloud_clip_edgeFUN_0048dd80(eax = the output, the two directions as six stack dwords,ret 0x18). It orders the ends itself, so the low end comes first: four times, M = normalize(A + B) replaces B when M.z > 12,000 and A otherwise. Then a linear step t = div16(A.z − 12,000, A.z − B.z) gives A + (B − A) × t (vector_scale), normalised again. Unlikehorizon_clip_edge, the linear step is kept and z is not forced. A strip with 2 points or fewer is skipped.The vertices, 0x20 bytes each from
DAT_005dafb4, one for each clipped direction d:- +0 x = cx − (mul16(div16(d·xs, d·fwd), sx) >> 16), and +4 y the same with ys, sy and cy. y is clamped to
DAT_00621394..DAT_00621398and then x toDAT_0062138c..DAT_00621390, but u and v keep the unclamped direction. - Without bit 4, the point on the plane: P = d × div16(h, d.z) (
vector_scale). Then (P + camera position) >>DAT_006218e0arithmetically on each axis, and +8 u = x' +DAT_006218e4, +0xc v = y' +DAT_006218e4. The same scroll goes on both axes, so the texture drifts diagonally. The texture is fixed to the world and repeats every 256 × 128 × 2^shift / 65,536 units, 4,096 for a shift of 13. - With bit 4, the dome: d × div16(0x10000, d.z) gives (dx/dz, dy/dz, 1), and u = mul16(dx/dz, K) + 0x4000 and v = mul16(dy/dz, K) + 0x4000. The zenith is at the page's centre, and the picture does not depend on the camera's position or the scroll. P is that vector × h.
- +0x10 the distance =
vector_length_x87(P) >> 15, and +0x14 = div16(0x10000, distance), its reciprocal. +0x1c is not written. - +0x18 the light (16-bit build). With e = distance × 8: when e >
DAT_006218f0, it isDAT_005dad7c+ mul16(e −DAT_006218f0,DAT_006218ec), otherwiseDAT_005dad7c. Then it is clamped above toDAT_005dad78and below toDAT_005dad7c. In the 8-bit build the base is 0x2000 and the slope twiceDAT_006218ec. - Each distance updates the running minimum
DAT_004eac18and maximumDAT_005dada8. Onlyrace_setupFUN_0049b9d4resets them (0x49b9eb: max 0, min 0x7fffffff), so they hold the extremes over the whole race so far, not this frame's.
- +0 x = cx − (mul16(div16(d·xs, d·fwd), sx) >> 16), and +4 y the same with ys, sy and cy. y is clamped to
Four depth slices, far to near. With r_far = div16(0x10000, max) and r_near = div16(0x10000, min), the step is (r_near − r_far) / 4, divided rounding toward 0. For k = 1..4 the boundary is b = r_far + k × step and D = div16(0x10000, b). The polygon is cut at the distance D: the part farther than D (distance > D) is drawn now, and the rest (distance ≤ D) is the input for the next k. After k = 4 the rest is dropped. Each piece is checked with its previous vertex through a jump table (
0x48c8e4):- both far: the current vertex goes to the drawn piece;
- current near, previous far: the crossing goes to both pieces and the current vertex to the rest;
- current far, previous near: the crossing goes to both and the current vertex to the drawn piece;
- both near: the current vertex goes to the rest.
A crossing gets x and y by t1 = div16(b − prev(+0x14), cur(+0x14) − prev(+0x14)), which is linear in the reciprocal, so the screen position is right. It gets u, v and the light by t2 = div16(D − prev(+0x10), cur(+0x10) − prev(+0x10)), linear in distance. Each value is prev + mul16(Δ, t), and it sets +0x10 = D and +0x14 = b. The drawn pieces are what make the affine spans close to perspective: one set of steps per slice and triangle.
A drawn piece with 3 or more vertices. When
DAT_006218d8is set it adjusts the lights first. With bit 4 every light becomesDAT_005dad7c. Without bit 4, a vertex within the sun box (|x − sun x| <DAT_005dad70and |y − sun y| <DAT_005dad74) getsint_min(box x − |dx|, box y − |dy|) × 16 added, capped atDAT_005dad78. Inferred: that branch is never taken, becauseDAT_006218d8is set only from bit 4 and the flags are only ever the track's or 0. Thenfill_cloud_spansFUN_00494ee8(eax = the count, edx = the stack copy of the piece), and the group count goes up by its result.At the end it stores the group count at
[DAT_005dada4].
Measured: the cloud layer (measure/render_cloud_layer.py, 2026-10-25). The harness checks the POD 2.8 executable hash, builds its tables, projection, view clip and cloud setup by running the game's routines, then calls draw_cloud_layer FUN_0048c8f4 at control word 0x27f. It records 787 layer calls and 1,317 direct cloud_clip_edge FUN_0048dd80 calls. The fixtures include plain and dome modes, 320 × 200 and 640 × 480 views, clipping ties, carried distance extremes and ten-frame sequences. The full group blocks, arena positions, mapped vertices, coverage writes, globals, strip and slice traces are saved. draw_cloud_spans FUN_004950d4 was also run on 46 layer cases with a deterministic synthetic page and shade table. Two full runs produced byte-identical files.
The camera, track words, mask, page and shade table are synthetic. No original track frame was run, and the fixture does not establish how the pass looks with the game's cloud textures. Thirty-four cases fault at 0x48d240 when the camera is close below the cloud plane; the reciprocal division overflows when a vertex distance is 2 or less. The 16-bit port must preserve or deliberately handle that fault. The 8-bit build, the layer's own slope clamp and a negative slope at draw time were not measured.
cloud_clip_edge orders the endpoints so the lower z comes first, then performs four midpoint normalizations. When a midpoint lands exactly on z = 12,000 it replaces the lower endpoint. Two equal direction vectors fault at 0x48de30. These edge cases are in the direct clip fixture.
Ported: the cloud layer (crates/pod-render/src/sky/cloud/, 2026-10-25). layer ports draw_cloud_layer FUN_0048c8f4 (with the depth slices in slices, the jump table at 0x48c8e4 and int_min FUN_0048de9c), clip ports cloud_clip_edge FUN_0048dd80, and spans ports draw_cloud_spans FUN_004950d4 on top of fill::cloud and span::cloud. sky::fixed::fixed_sqrt is FUN_004d3e90 (the dome's K). Re-read in the disassembly of target/pod/disc/WINPOD.EXE while porting (observed):
- every crossing in the elevation clip is
cloud_clip_edge(current, previous), into the next free slot; the routine orders the ends itself; - the arena pointer moves on by the count dword before the height test, so an early return leaves it 4 bytes on with the dword unwritten;
- the sun flag is cleared after the first strip side is normalised, and the sun's x is stored before its y is divided;
- a vertex's x is stored before its y is divided, then y is clamped, then x; with bit 4 the clipped direction is scaled in place by 1 / z, then by h;
- the input and rest buffers of the depth slices swap after every slice, so the vertex buffer
0x5dafb4takes the rest of slices 2 and 4, and the stack buffer at[esp]that of slices 1 and 3; the drawn piece is always[esp+0x400]. A crossing writes x, y (by t1), u, v, the light (by t2), +0x10 and +0x14 into both pieces and leaves +0x1c as the slot held; draw_cloud_spanscallsplace_sun_markeronly when bothDAT_005dad88andDAT_006218d8are set; the port returns that position for the caller.
Every idiv is checked: a zero divisor or a quotient outside 32 bits returns Fault { eip } at that instruction, and the state is what the instructions before it left (the count dword unwritten; arena, mask, scroll, sun and distance extremes as far as they got). Nothing is clamped or invented. vector_normalize's own division faults at 0x423c2e for a length of 1 or 2.
Measured (tests/cloud_layer.rs against derived/measure/cloud-layer/): all 787 layer calls match: the fault (the 34 divide errors at 0x48d240), every global after (20 the port keeps; the other 17 recorded are unchanged by the original), the block's bytes, that no other arena byte changed in a fresh case, every changed mask dword, and the hooks' records (the frame, each strip's quad, clipped directions and vertices, each slice, and each fill's vertices before and after mapping). The 102 carried frames of the 12 sequences run on the port's own carried state (globals, arena and vertex buffers), checked against the recorded state before each frame. The 46 frames of the draw pass match byte for byte, with the sun marker calls. All 1,317 direct clip cases match: output, the four midpoints and the 12 same-vector faults at 0x48de30. Deliberate faults in the port are caught (each tried once): a midpoint tie going to the high end (12 clip cases), no end swap, a strict "previous near" test, t2 taken from the reciprocals, the sun flag not cleared, an early return writing the count, a reciprocal that does not fault, extremes reset per frame, no upper light clamp, the sun box ignored, the marker without DAT_006218d8, and +0x1c written in a crossing. Two changes are not caught because they cannot change a result: clamping x before y (the clamps are independent) and flooring the slice step (r_near ≥ r_far, so the difference is never negative).
Limits: the screen maps are identity over every mapped vertex in the fixture (each fill's vertices are the same before and after map_vertices), so other maps are untested. The 8-bit light (0x48d84a) is not ported. The stack buffers start as zeros (the game's hold stale stack); only +0x1c of a crossing can show it, and the filler does not read +0x1c. The vertex buffers hold 32 vertices; the original's 0x5dafb4 is followed by other globals, so a strip past that would overwrite them (not reached: at most 6 vertices a slice in the fixture). Inputs are the measurement's synthetic ones.
The block's order. Strips go from the +xs side to the −xs side. Within a strip the slices go far to near, and within a slice the triangles go in fan order. Every kept slice is one group. A slice with no visible triangle moves the arena back and is not counted.
fill_cloud_spans FUN_00494ee8. It maps the vertices (map_vertices FUN_00492e34) and reserves the group's triangle count at DAT_005f5410. Then it fills the triangles (v0, v(k+2), v(k+1)) for k = 0..n − 3, through a jump table at 0x494ed0 for 3 to 8 vertices. That is fill_fan_spans' order with its flag 8 (the last two swapped). It stores the number of kept triangles and returns 1, or with none kept moves the arena back and returns 0. Any other count leaves the result in esi unset (the same hole as fill_fan_spans). Inferred: a clipped strip has at most 5 vertices and each depth cut can add vertices, so a slice of 9 or more is possible in principle. How often it happens is unknown.
fill_cloud_triangle FUN_00494c00 is fill_triangle_spans ("Flat faces") with two changes:
- it always marks the coverage mask through
coverage_mark_spans_at(from mask rowDAT_0061ea1c+ ymin × 0x50), with noecxswitch; - it computes the light step at every width (no w > 10 test like 0x4947df).
The edges, the middle-row gradients, the 2^9 scaling, the negative-width drop after marking, the header (first row, h, u step, v step, light step) and the (5h + 5) × 4 arena step are the same. With the fan order above, its edges a→b→c run v0 → v(k+2) → v(k+1).
draw_cloud_spans FUN_004950d4 (eax = the block, edx = the page [DAT_0061ffe8 + DAT_006218c4 × 4 + 0xc]; called at 0x49b356 after clear_uncovered). For each triangle, with header steps A (u), B (v) and C (light) and n rows:
- edi =
DAT_00621bb0+ first row ×DAT_006213ac; - eax = (A << 16) | ((C sar 8) & 0xffff);
- ecx = (B << 16) | (A >> 16), a logical shift;
- edx = (n << 16) | (B >> 16);
- ebx = the page, esi = the records.
Then it calls span_cloud. The next triangle starts n × 0x14 bytes after the records. After all groups comes the sun marker (above).
span_cloud FUN_004d6900 (502 bytes; observed, and measured as described below under "Measured: span_cloud"). It is span_gradientFUN_004d6af6 with a different carry path for v's integer part.
- Steps. The row count is edx >> 16. Bit 16 is cleared in each step word: S0 = eax (u's fraction over the light's 8.8), S1 = ecx (v's fraction over u's integer byte), S2 = the low byte of edx (v's integer step). It stores them at
0x4ef2e0,0x4ef2dcand0x4ef2e4, and the doubled chain (2·S0, 2·S1 + carry, 2·S2 + carry) at0x4ef2ec,0x4ef2e8and0x4ef2f0. The page goes to0x4ef2f4and is not read back here. - Position. Each record holds x0 (+0), u (+4), v (+8), the light (+0xc) and x1 (+0x10), all in the filler's 2^9 scale. ebx holds u's low 16 bits over the light's bits 8..23, so bh is the shade row. eax holds v's low 16 bits over u's bits 16..31, so al is u's integer byte. cl is v's bits 16..23.
- Stepping. One step is ebx += S0, eax += S1 + carry, cl += S2 + carry. Before each
adc clthe routine loads the v step byte into ah (or ch). So the byte left in ah joins the nextadc eax, and a carry out of u's integer byte can reach v's fraction. A port has to model the registers, not three clean fields. - The texel is the byte at (page & 0xffff0000) + (al << 8) + cl. u is the high byte and v the low byte, so the page must be 64 KB aligned (inferred, as for the horizon). In the page's rows of 256 bytes, world x picks the row and world y the column (inferred from the vertex u and v).
- The pixel is the shade dword at
0x634dcc+ (bh × 256 + texel) × 4, the low word for one pixel or the whole dword for two. With a vertex light of 0x6080..0x6f80 the row is the light shifted right 7, 0xc1..0xdf: the cloud rows above. Texel 0 is drawn like any other: no key and no mask. - The order. A row with x1 − x0 ≤ 0 draws nothing. Up to 5 pixels it draws one texel a pixel with single steps. A longer row draws one texel a pixel up to the first 8-byte boundary. Then, up to the last 8-byte boundary, it draws two texels per 8 bytes, each as a dword (two pixels), with doubled steps; both texels of a group use the shade row from the group's start. Then one dword with one more step when bit 2 of the end address is set, and one word with no step when bit 1 is set. In those tail stores some of ch, cl and dh can be one step behind the others, depending on which loops ran.
What a port needs (all for the 16-bit build):
- the camera record and divisors that
build_horizonuses; - the eight cloud words and the light direction from the track file;
- the cloud block's first page and slot 1's palette for rows 0xc0..0xdf;
DAT_004eac18andDAT_005dada8kept across frames fromrace_setup;DAT_006218e4kept across frames from track load;- the arena and coverage mask shared with the world's fills.
Measured: span_cloud (measure/render_cloud_spans.py, 2026-10-25, POD 2.8 target/pod/disc/WINPOD.EXE; the harness refuses a build whose routine bytes hash differently). The harness packs a triangle header (A, B, C, n) into eax, ecx and edx as listed under draw_cloud_spans (packing re-read at 0x49513c..0x495177, observed), runs FUN_004d6900 under Unicorn at control word 0x27f, and keeps the frame bytes: 2,081 calls, 8,681 rows. They include n = 0, n = 0x10000 (dropped to 0) and 0x10001 (one row). Rows of width ≤ 0 are included, and one whose x1 − x0 wraps to +2 draws at row − 2. Every x0 phase 0..3 is paired with every width −3..26. The long rows run from 27 to 640 pixels. All 16 start and end 8-byte phases are covered with the doubled loop, plus the one case without it (start phase 2, end phase 6, 6 pixels). The edge step values in every (A, B) pair and edge positions cover the carries from the light fraction to u, u to v, and v to cl. The game-like light rows are 0xc1..0xdf, pitches are 0x500 and 0x280, and frame offsets are 0, 2, 4 and 6 bytes. The shade table and page are random from a fixed seed.
Every case also runs through a register model of the trace above, which the harness includes as a check. Its output matches the original's bytes in all 2,081 cases (measured). Three changes to the model are each detected: an up-to-date ch in the tail, a fresh shade row for the second texel of a pair, and no overwrite of ah. So the trace's carry path is right bit for bit: ah is overwritten with the v step byte at every step, a pair shares one shade row, and ch is one step behind after the single-pixel loop. The model keeps dh behind too; no change to dh was tried, so that part is only as good as the cases that cross a shade row at that point. Two runs give the same files (sha256 in summary.json).
Fixtures, all in derived/measure/cloud-span/ and ignored by git:
fixture.binholds the shade table (65,536 u32 at0x634dcc, row << 8 | texel), then the 64 KiB page.cases.binstarts withPODCLDS1and a u32 count. Each case holds 11 u32 (A, B, C, n, pitch, edi offset from the frame start, record count, rows kept, the packed eax, ecx and edx). The records follow (i32 x0, u32 u, v and light, i32 x1), then rows kept × pitch frame bytes, which were 0xa5 before the call. The first row is one pitch into the frame.
Limits: the inputs are synthetic, so no game frame is recorded. The page is always 64 KiB aligned. x0 and x1 stay inside the row except in the one wrapping row, and odd frame addresses are not tried. draw_cloud_layer and draw_cloud_spans themselves are not measured; fill_cloud_spans and fill_cloud_triangle are (next paragraph).
Measured: fill_cloud_spans and fill_cloud_triangle (measure/render_cloud_fill.py, 2026-10-25, POD 2.8 target/pod/disc/WINPOD.EXE; the harness refuses a build whose bytes of FUN_00494ee8, FUN_00494c00 with its jump table, the two edge walkers, map_vertices and coverage_mark_spans_at hash differently). The renderer tables come from FUN_00495b14 run once at control word 0x37f; that state is snapshotted, and each case starts from the snapshot (image, registers and scratch memory restored) and runs FUN_00494ee8 at control word 0x27f with eax = the count and edx = the vertices. Re-read in the disassembly while building it (observed): the routine keeps the count in edi and the vertices in ecx, calls map_vertices with eax = vertices and edx = count, and passes each triangle as eax = v0, edx = v(k+2), ebx = v(k+1). fill_cloud_triangle indexes the reciprocal table 0x5db7b8 by the middle width, so w = 0 gives a step of 0 from entry 0 (the triangle is kept).
4,264 cases, 13,602 triangles, all with counts 3 to 8 (measured):
- every weak ordering of the three y values of a triangle (13, ties included) against every winding (positive, negative, collinear), for each count 3..8: no cell of that matrix is missing (an all-equal y is only ever collinear);
- 529 triangles dropped with h ≤ 0, 7,044 hidden by the mask, 176 dropped for a negative middle width after marking, and 5,853 kept; 214 kept triangles have a middle width of 0 and 809 one of 1..10. Of those 1,023 narrow ones, 729 have a light step that is not 0, which confirms that the cloud filler has no w > 10 test;
- 370 kept triangles with a flat top and 370 with a flat bottom; vertices on x = 0, x ≥ 639, y = 0 and y ≥ 479; the full screen (0, 0)..(640, 480) in both windings;
- u, v and light game-like (|u|, |v| ≤ 0x40000, light 0x6080..0x6f80; 2,177 kept), the dome's constant light (622 kept with equal lights), and values around and past the << 9 wrap and full 32-bit (2,807 kept), so the
imul/shrdproducts and the step multiply overflow; - 1,802 empty fans (the arena moves back, return 0) and 2,462 kept ones; masks that hide nothing, some rows, everything, only the rows around the middle row, left or right parts of each row, or random spans; identity and uneven screen maps, offsets and the split screen; arena start positions 0..0x3fc bytes into the buffer.
What the cases show (measured): the return is 1 exactly when a triangle is kept, the stored count is the number kept, and the arena ends at start + 4 + Σ(5h + 5) × 4 over the kept triangles. A slice whose only triangle is dropped for its negative middle width still leaves its marks in the mask (11 cases). Kept triangles have a negative cross product (b − a) × (c − a) in screen coordinates (y down) for the triangle (v0, v(k+2), v(k+1)): 5,766 of 5,853. So in the slice's own order v0, v1, v2 runs clockwise on screen. The other 87 are collinear (48) or slivers whose rounded edges give a non-negative middle width (39).
Fixtures, all in derived/measure/cloud-fill/ and ignored by git: cases.bin (layout in the harness's docstring: header PODCLDF1, the case count, arena base, mask address and arena fill word, then per case the inputs: count, offsets, split flag, the maps when not identity, the arena start, the vertices and the mask as spans; and the outputs: return value, arena pointer after, every arena byte from the start to one past the last byte written, records of dropped triangles included, the vertices after mapping, every changed mask dword, and a diagnostic path code per triangle) and summary.json (counts per group, per count and per path, the ordering × winding matrix, routine hashes and the sha256 of cases.bin). Two runs give the same bytes (sha256 763464e8…74fa5 with the default 1,200 random fans).
Limits: every input is synthetic; no game frame or real cloud slice is recorded. Mapped coordinates stay in 0..640 × 0..480, as draw_cloud_layer's clamp leaves them, so the coverage routine never sees x < 0, y < 0 or a span wider than 640 (a 640-wide mask span is written in two halves). Counts outside 3..8 are not run (their result register is unset, observed). Equal ends on the middle row's right edge (0x494ddc) cannot be reached with h > 0, and the hook that watches it never fired. The scratch words 0x4ee848..0x4ee854 that coverage_mark_spans_at leaves are not recorded.
The fixed-point helpers (dot16, vector_scale, vector_scale_into, vector_normalize, vector_length_x87 at control word 0x27f) and the triangle edges are already in pod-render (sky::fixed, fill::edges).
The sun: place_sun_marker FUN_0048c100 and draw_sun_flare FUN_0048c248
POD 2.8's WINPOD.EXE (target/pod/disc/WINPOD.EXE, SHA-256 eacfb3d2…8084), read in its machine instructions on 2026-10-25: place_sun_marker and draw_sun_flare (0x48c100 to 0x48c8e4), their two call sites, sin16/cos16, draw_shade_spans and span_whiten_3. objdump refuses this file ("RVA 0xdf000 for import table not found"; its sections have a VirtualSize of 0), so the instructions were decoded with Capstone from the mapped image (notes in analysis/notes/sun.s, not tracked). Unless marked, the behavior below is observed in those instructions. The pass draws a lens flare: a star of thin spikes and a small octagon around the sun's screen position. It whitens pixels already in the frame. It runs only when the sun pixel was not covered by anything drawn after the clouds. The triangles and full-frame output are measured by measure/render_sun_flare.py; all inputs are synthetic and the frames are not a capture of a POD race.
When it runs (draw_frame_views FUN_0049af38, per view, 0x49b339..0x49b399):
- When
DAT_006218c8≠ 0:draw_cloud_spansFUN_004950d4(eax =DAT_005dada4, edx = the cloud page). At its end, whenDAT_005dad88≠ 0 andDAT_006218d8≠ 0, it callsplace_sun_markerwith eax = sun xDAT_005dad9cand edx = sun yDAT_005dada0(0x4951b6..0x4951dc). - When the byte
DAT_00621071≠ 0:draw_horizonFUN_00495804. - Always: the depth buckets
FUN_004992c8, the world drawn far to near. - When
DAT_006218c8≠ 0:draw_sun_flare(0x49b38b), with no register arguments. - When the byte
DAT_0058d57d≠ 0: the overlaysFUN_0049ac2c.
So a horizon or world span over the sun's pixel overwrites the marker and hides the flare. DAT_005dad88 is first written as a flag by draw_cloud_layer (0x48cd95, 0x48d68d), and DAT_006218d8 is written at 0x48dc96, 0x48dd11 (cloud_setup) and 0x4978ca. In a linear sweep of the code section, no instruction outside these functions reads DAT_005dad98, DAT_005dad8c or DAT_005dad90. Which tracks can show the flare is the flags table's business ("The clouds"; inferred there: BABELWEB, CANYON, DOWNTOWN, VEITLAND and TEST).
place_sun_marker (eax = x, edx = y; preserves ebx, ecx, ebp):
| Step | What |
|---|---|
| scale | DAT_005dad98 = 25,000 (0x61a8) when the width DAT_00621220 ≤ 400 (signed jg), else 50,000 (0xc350). draw_sun_flare reads it as R (below) |
| bounds | off when x ≥ DAT_00621390 − 1, x ≤ DAT_0062138c + 1, y ≥ DAT_00621398 − 1 or y ≤ DAT_00621394 + 1 (signed): DAT_005dad88 = 0 and return |
| keep | DAT_005dad8c = x and DAT_005dad90 = y, unmapped |
| screen | DAT_005dad80 = the dword at 0x6a4dd0 + (x & 0x3ff) × 4, DAT_005dad84 = the dword at 0x6a5dd0 + (y & 0x3ff) × 4. No view offset is added (no read of DAT_0062139c/DAT_006213a0), unlike fill_polygon_spans' FUN_00492e34 |
| offset | full screen: mapped y × row bytes DAT_006213ac (imul). Split screen (byte DAT_00620fd0 & 2): the low byte of DAT_005dad84 gets its bit 0 cleared (or 1, then xor 1, written back), and the product uses row bytes sar 1. Then x: + mapped x × 2 when the byte DAT_00634d17 ≠ 0 (the 16-bit build), else + mapped x × 4 when the byte DAT_00634d16 ≠ 0, else + mapped x |
| marker | DAT_005dad88 = that offset (until now a 0/1 flag). Byte 0x12 at DAT_00621bb0 + offset, byte 0x34 one after it |
draw_sun_flare (no register arguments; saves ebx, ecx, edx, esi, edi, ebp; 0x418 bytes of locals). Registers in the body: edi = a and esi = b, the rotation-and-scale vector; ebp = the light scale. Locals: three template vertices of 0x20 bytes at esp+0 (x +0, y +4, light +0x18), the span list at +0x400, the wedge counter +0x404, the pass counter +0x408, the spikes per pass +0x40c, the spike counter +0x410, the turn angle +0x414.
- Test. Return unless
DAT_006218d8≠ 0,DAT_005dad88≠ 0, the byte atDAT_00621bb0+DAT_005dad88is 0x12 and the next byte is 0x34. The marker is never restored or cleared here. Measured: the flare draws over its own marker in every valid case; the resulting pixel depends on the pixel format and on the dither switch. - Set-up. Template: (0, 0) light 0x2c78; (0, w) light 0x2000; (0x15e0000 = 350.0, 0) light 0x2000; w = 0x20000 (2.0) when the width ≤ 400, else 0x30000 (3.0). Spikes per pass 4, turn angle 0x19220 (π/2), pass counter 0, ebp = 0x10000. Then a = mul16(sin16(0x19220), R) and b = mul16(cos16(0x19220), R), with R =
DAT_005dad98in edx across the call (sin16pushes and pops edx). mul16 isimulthenshrd16: the signed 64-bit product, bits 16..47.sin16(0x19220)is the table entry 0x3244 andcos16(0x19220)is entry 0 (cos16 adds 0x19220; sin16 folds 0x32440 to 0x3243f − 0x32440 + 4,sar3). So a = R and b = 0 (inferred values, from the table formula). - One triangle, two copies. For each of the three template vertices (x, y, L), copy 1 is X = sar16(mul16(a, x) + mul16(b, y)) +
DAT_005dad8c, Y = sar16(mul16(b, x) − mul16(a, y)) +DAT_005dad90; copy 2 is X = sar16(mul16(a, x) − mul16(b, y)) +DAT_005dad8c, Y = sar16(mul16(b, x) + mul16(a, y)) +DAT_005dad90. The sum is shifted, not each product. Both write +0 X, +4 Y and +0x18 = mul16(L, ebp) into the shared buffer0x5dafb4; +8, +0xc, +0x10 and +0x14 are left as they were. Copy 1 is copy 2 with the template's y negated: the pair is symmetric about the spike's axis. Order: copy 1 built, clipped and drawn, then copy 2. - Each copy is drawn by
clip_polygonFUN_00491b84with eax =0x5dafb4, edx = 3 (read as dl), ebx = 0x20 (bl: the light only, no flag 4, so the straight cut). When it returns more than 2,fill_polygon_spansFUN_004931ecwith eax =0x5dafb4, edx = the clipped count, ebx = 0x20 (light filled, no 0x40, no side swap 8), ecx = 1 (no coverage test) and one stack argument, the address of the local at +0x400 (the callee returns withret 4). When that returns non-zero,draw_shade_spansFUN_00492fecwith eax = the list from +0x400 and edx = 0x80000000. Each triangle is filled and drawn before the next is built, so later triangles whiten pixels that earlier ones already whitened (inferred effect of the observed order). - The turn after each spike. c = cos16(turn), s = sin16(turn): a′ = mul16(a, c) + mul16(b, s), b′ = mul16(b, c) − mul16(old a, s). With turn = π/2 (c = 0, s = 0x10000) that is exactly a′ = b, b′ = −a, with no drift (inferred from the table values).
- Two passes. Pass 0 draws 4 spikes. After a pass: when the pass counter > 1, halve the turn and double the spike count; the counter is 0 or 1 there, so this never runs. Then one turn by turn
sar1 = 0xc910 (π/4). Then, when the pass counter < 4 (always): a, b = mul16(·, 0x4e20 = 20,000) and ebp = mul16(ebp, 0xfa00 = 64,000). The counter goes up and pass 1 repeats from step 3 without recomputing a and b. Pass 1's spikes are therefore 45° off pass 0's, scaled by 20,000/65,536, with light scale 64,000. After pass 1 the same turn and scaling run again: a and b are thrown away, ebp becomes mul16(64,000, 64,000) = 62,500 (0xf424), exactly. - The disc. Template: (0, 0) light 0x3060; (cos16(0xc910) × 20, sin16(0xc910) × 20) light 0x2000 (× 20 as
shl2, add,shl2); (0x140000 = 20.0, 0) light 0x2000. cos16 and sin16 of 0xc910 both read table entry 0x1922, so that vertex is exactly on the diagonal. a and b are recomputed as in step 2 (a = R, b = 0). 4 wedges, each as two copies (steps 3 and 4), with a fixed π/2 turn (0x19220) after each. Together: an octagon with a vertex every 45°. Light scale 62,500.
Directions and sizes (measured at widths 320 and 640; screen y grows down, since the marker's offset is y × row bytes):
| Part | Directions | ≤ 400 wide (R = 25,000) | wider (R = 50,000) | light (centre, edge) |
|---|---|---|---|---|
| pass 0 spikes | right, up, left, down | tip 133 or −134 px; base half-width 0 or −1 px | tip 267 or −268 px; base 2 or −3 px | 11,384 (0x2c78), 8,192 (0x2000) |
| pass 1 spikes | up-right, up-left, down-left, down-right | tip about (28..−29, 28..−29) px; base 0 or −1 px | tip about (57..−58) px each way; base 0 or −1 px | 11,117, 8,000 |
| disc | vertex every 45°, first at right | radius 7 or −8 px axially, (5..−6) diagonally | 15 or −16 px axially, (10..−11) diagonally | 11,810, 7,812 |
The asymmetry (133 against −134) comes from sar rounding toward −∞. Measured: at 320 wide, one copy each of the right and left pass-0 spikes has two vertices at the centre and zero area, so 22 of 24 triangles are filled. All 24 are filled at 640 wide. The fill's handling of the surviving triangles is ported (pod_render::fill).
draw_shade_spans FUN_00492fec (also kind 0x20 faces in draw_face FUN_00499080). eax points at a span list: dword 0 is the first row, dword 1 the row count, the records from +8. It sets edi = DAT_00621bb0 + first row × DAT_006213ac, ecx = the row count, esi = the records. Then by edx:
- bit 31 clear, or bit 31 set with
DAT_004eac84= 0 and the byteDAT_00621070above 3:span_shadeFUN_004d4f00with eax = edx (so al is the colour). - bit 31 set and the dword
DAT_004eac84≠ 0:span_dither_whiteFUN_004d4fe2with eax = the first row, whatever the format byte (the dither test comes first, 0x493011). WhatDAT_004eac84means here is unknown. - bit 31 set otherwise: through the jump table
0x492fdcbyDAT_00621070: 0span_whiten_0FUN_004d543f, 1span_whiten_1FUN_004d5288, 2span_whiten_2FUN_004d5622, 3span_whiten_3FUN_004d50a5.
The flare passes edx = 0x80000000, so it always whitens or dithers: never span_shade, unless the pixel format byte is above 3. In span_whiten_3 (instructions read): the light's second byte (bh) must be ≥ 0x45 (an unsigned compare against 0x45 skips the pixel below it). The pixel already there is masked with 0x739c and packed into a 9-bit colour (3 bits a channel). The new pixel is the low word of the dword at 0x674dcc + ((bh & 0x1f) ^ 0x1f) × 512 + colour, × 4. The other three span_whiten_* were read only in pseudocode (masks 0xe38c, 0xe71c, 0xe738). The table at 0x674dcc is built by build_whiten_table FUN_004974b4 (pseudocode only): rows below 32 mix the colour toward white, row 0 the whitest.
Measured, from the full-frame runs: the centres' light bytes are 0x58 (pass 0, row 7), 0x56 (pass 1, row 9) and 0x5c (disc, row 3). The edges are 0x40, 0x3e and 0x3d, below the 0x45 cut-off, so every part fades out before its edges and tips. A light byte of 0x60 or more would wrap to row 31, the faintest. The disc's unscaled centre, 0x3060 doubled, is exactly 0x60. The 62,500 light scale keeps it at 0x5c.
What a port needs: the sun's position and marker from the cloud pass; the clip rectangle and screen tables; clip_polygon and fill_polygon_spans (both ported, with the flags above); the whiten drawers and their table; and the integer steps above, in the order above. The measurement fixtures below preserve complete frame output and the triangle and span inputs for a Rust replay test.
Measured (measure/render_sun_flare.py, 2026-10-25, POD 2.8 WINPOD.EXE, control word 0x27f): 94 independent cases, each with a fresh emulator state. The game builds the renderer, sine and whiten tables; the harness calls place_sun_marker, then draw_sun_flare, and saves the full frame before and after. Every valid marker case draws 24 triangles; the 320 × 200 cases fill 22 of them and the 640 × 480 cases fill all 24. Fourteen disabled, out-of-bounds or overwritten-marker cases leave the pre-call frame unchanged. The sine table has no mismatch against the formula checked in the harness. DAT_004eac84 = 1 selects span_dither_white and produces the same frame for all four pixel formats; when it is 0 the format selects one of the four whiten drawers. The marker itself changes under the flare. The frame guards are unchanged, and the flare preserves its saved registers.
Fixtures in ignored derived/measure/sun-flare/ include the synthetic frames, game-built tables, case inputs, frame-precall.bin, frame-after.bin, 24 triangle traces per valid case, resulting span lists where filled, and summary.json. Its SHA-256 is 8b7c4120b9d94581a174df3c83b712242e92e7f402cbceef73a77984a617d4e5. The inputs do not cover split screen or 8/32-bit marker offsets. No original race frame was captured.
Ported (pod_render::sun, 2026-10-25): place_sun_marker, draw_sun_flare, the high-bit branch of draw_shade_spans, the four span_whiten_*, span_dither_white and rows 0..31 of build_whiten_table (its four builders at 0x496c24, 0x496e4c, 0x497068, 0x497280, read in their instructions). It reuses clip_polygon (pod_render::clip), fill_polygon_spans (pod_render::fill) and sin16/cos16 (pod_physics::math). tests/sun_flare.rs replays all 94 cases: marker globals, the frame before and after, the marker bytes, every triangle's vertices before and after the clip, its fill result, span records, list address and drawer, plus the whiten, sine and reciprocal tables and the 40 reruns with leftover vertex words 0x5a. Every byte matches. Two details the replay depends on, observed in the instructions and confirmed by it:
- The whiten drawers work in groups of four pixels. The first pixel reads the dword under it and keeps it in
DAT_004ee150. The second takes its colour from that dword's high word, and the third and fourth take theirs from the kept dword again, not from the frame. So pixels 2 and 3 of each group are whitened from the colours pixels 0 and 1 had. The light is stepped before every pixel, the first included. span_dither_whitewrites its 16-bit light step into its own code (the immediate of theadd bxat 0x4d506d), so the light wraps within 16 bits. It takes the pattern column from the pixel's address bits 1..3 and the pattern row from(first row + 1 + k) & 7, and it swaps a record's two ends in the list when the second x is the smaller.
Left out, each marked Deviation: in the code: the split screen and the 8/32-bit marker offsets, format bytes above 3 (span_shade), and whiten rows 32..63. In apps/unpod, the cloud pass places the marker before horizon drawing; render_pod.rs draws the F0/RGB565 flare after textured and color world faces. Dithering is disabled and its executable threshold table is not copied. The stand-in renderer does not draw the flare, and no original race frame has been captured to verify the full composition.
Ported and measured
crates/pod-render/src/sky/: horizon (build_horizon, its clip and seam), horizon_fill (fill_horizon_spans), fixed (the 16.16 helpers dot16, vector_scale, vector_length_x87, vector_normalize, with the x87's rounding at control word 0x27f), panorama (drawing the lists) and draw_sky, which builds and draws the horizon over a full-screen frame from a camera basis and the track's band and horizon page. Camera::from_angles is an example helper, not the game's camera code.
pod_render::span::cloud ports span_cloud FUN_004d6900 and the caller's register packing from draw_cloud_spans FUN_004950d4. Its 2,081 Unicorn cases replay byte for byte (measure/render_cloud_spans.py, tests/cloud.rs). pod_render::fill::cloud ports fill_cloud_spans FUN_00494ee8, fill_cloud_triangle FUN_00494c00, and their edge walkers. The tests/cloud_fill.rs replay checks all 4,264 measured cases against POD 2.8, including the entire span arena, mapped vertices and coverage mask. pod_render::sky::cloud ports the layer, its clip and its draw pass for the 16-bit build and replays the 787 layer cases, the 1,317 clip cases and the 46 frames ("Ported: the cloud layer"); draw_sky does not call it.
Measured (measure/render_sky.py, 2026-10-25): 1,500 random cases through build_horizon itself under Unicorn at control word 0x27f — cameras of any heading, pitches mostly within ±0.6 rad and some to ±1.4, a third with roll; 640 × 480 or 320 × 200; random divisors; full, letterboxed or inset clip rectangles; half with the bands of POD's tracks and half random; a random coverage mask three quarters of the time. The port leaves the same arena words in every case (10,610 lists). cargo test -p pod-render --test sky replays them and checks draw_sky on a synthetic page (the band in the view's middle row, nothing at pitch ±1.5 rad, a full turn the same frame, a quarter turn 256 columns).
Deviations: the panorama's acos and asin use the f64 library instead of fpatan at 64 bits (the angle is stored as a float, which hides the difference in the measured cases); a division by zero or an overflowing quotient wraps where the original faults; and sky::panorama uses a plain affine sampler, one texel a pixel. The exact packed span implementation is pod_render::span::horizon::horizon; draw_sky does not call it yet.
Open for the sky:
- The app wires the cloud layer, clip and draw pass in
apps/unpod/src/sky.rs; the standalonepod_render::sky::draw_skyremains horizon-only. Whole-frame parity and the following layer details remain open:- what the unwritten group count holds in the game after the height test's early return, and whether
draw_cloud_spansthen walks garbage (measured in the harness only: the arena's fill or the previous frame's count, as the arena starts at the same place; in the game the arena position depends on the world's fills); - the stale
DAT_005dad88on that path (measured: kept); - the divide error at
0x48d240with the camera within about 0x10000..0x16000 below the plane: whether the game can reach it, and whether a port should copy or guard it (the port returns the fault and leaves the choice to its caller); - how often a depth slice has more than 8 vertices;
- the first frames of a race, while the running distance range is still growing;
- who reads
DAT_006218dc; - whether
track_render_setupcan run twice on one track and negateDAT_006218ecback; - the 8-bit build's path, which is left out.
- filler inputs outside the fixture's bounds (mapped x/y outside 0..640 × 0..480, width ≥ 10,000, or negative ymin); these can reach Rust bounds checks where the original would access memory.
- what the unwritten group count holds in the game after the height test's early return, and whether
- The sun's marker and flare are measured and ported ("The sun";
pod_render::sun), then wired to thepod-renderapp path. Open: whatDAT_004eac84means in the game, split screen (where the marker has no view offset but the filler adds one), and the 8/32-bit builds. The stand-in path's flare and the dithered flare are not ported. span_horizonFUN_004d81baanddraw_horizonFUN_00495804are ported and measured.sky::draw_skystill calls the affinesky::panoramasampler instead of that exact path.- The camera:
draw_skytakes a heading and a pitch; the game's camera record comes from the camera code (FUN_0049b6e4copies it), not traced here. A frame comparison against the recorded Beltane frame needs that record (it is not in the frame log). - The projection divisors for 640 × 480 (
focal) are from the disassembly, not measured. - The app's camera basis is Un-POD's chase-camera stand-in; the original camera record copied by
FUN_0049b6e4has not been traced. - The app uses
Sky.words[4]as the BkGround flag; theUbi.inioverride is not ported.
Measured
Under Unicorn (measure/render_tables.py, measure/render_spans.py, measure/render_shade_coverage.py; output in derived/measure/render/), 2026-10-24:
- The tables: every entry, at control words 0x37f and 0x27f.
- The horizon's span routine
FUN_004d81baand passFUN_00495804: 4,000 span lists and 400 list blocks ("The horizon"). - Kind 0x60 and kind 0x61: 4,000 random span lists each (1 to 6 rows, perspective and affine, flipped and not, depth terms either side of 0x12c00, a random shade table and texture page), half at each control word. Every byte of the frame is the same.
- The shade rows and the four coverage routines: 800 cases each.
- The horizon's builder
FUN_00490544: 1,500 cases ("The sky").
crates/pod-render's tests replay all of them (cargo test -p pod-render).
Against a frame of the game
The race-loop session recorded one frame of POD in the emulated PC (Beltane, easy, about 14 seconds after GO!; run/vm/framelog.py, kept in derived/render/pod-beltane/). The recording has every call of draw_span_dispatch with its span list, texture page and target row, the five texture pages, the shade table, the control word at every span call, and the frame as draw_frame_views left it. crates/pod-render/examples/frame_replay.rs replays the 55 calls (2,577 spans, all kind 0x60) through the port and compares (measured, 2026-10-24):
- The spans write 272,446 pixels. 271,467 of them are the same as POD's frame.
- All 979 others come from one call: the player's car. In POD's frame those pixels are black (704) or 0x0820 (275). Both are written after the span calls. Black is
clear_uncoveredclearing what the coverage mask leaves open. 0x0820 does not come from the horizon passFUN_00495804, as first guessed: its span routine takes every pixel from shade row 0xe0, and the frame's row 0xe0 has no 0x0820 (measured on the recorded shade table; 0x0820 is in rows 2..21 and 66..87, the track's and the cars' dark ends). Where it comes from is open. - With 64-bit precision instead, 5,404 pixels differ.
So every pixel the dispatcher draws matches. The frame replay omits the cloud layer, far scenery, coverage marks and overlays. The horizon pass and span routine have separate measurements above.
In Un-POD
apps/unpod draws a race with pod-render when UNPOD_RENDERER=pod-render (render_pod.rs). The app still gets projected triangles from view::project and clips them in its own scene path. pod-render applies its depth buckets, fillers, span routines, shade bands and coverage mask. The measured transform and clipping routines are not wired into the app, and its camera, lighting and depth inputs remain stand-ins marked with Deviation: in the source.
With the default standin renderer, a pixel whose projected triangle has no resolved page is still painted generic gray ([200, 200, 200] before shading; apps/unpod/src/raster.rs). The POD renderer path now carries the source kind and color through world::add and view::project: draw_face (FUN_00499080) sends kind 0x10 (FLAT) to draw_flat_spans (FUN_00492f9c), kind 0x20 (GOURAUD) to draw_shade_spans (FUN_00492fec), and kinds 0x40/0x60 to the texture span path. The app routes the first two through pod_render::span::color after drawing the textured scene. Observed: every face name in POD's .BL3 tracks, environment objects, and .BV3 cars is FLAT, GOURAUD or TEXGOU (world::tests::every_loaded_pod_track_and_car_face_kind_has_a_dispatch); no Other kind occurs in those files. Measured: synthetic flat, Gouraud and overlap checks exercise the app calls to the measured drawers (render_pod::tests). Visible track faces are emitted, source face flags are carried to the renderer, and every textured page index resolves (world::tests::every_track_face_and_texture_page_resolves).
pod-render::scene now uses flag 4 to select the measured row-span fill path, and the clear-bit path uses the measured affine fan filler and gradient drawer. Flag 1 controls fan coverage and keyed texel-zero drawing; flag 8 preserves two-sided winding. view::project uses the POD race projection's measured near, face-cut and far-plane ratios. It splits source quads for output but shares route and facing decisions across both halves using the original corners. Its camera and clipping arithmetic remain floating point. This wires both measured textured paths into the app; it is not a frame-parity result.
This does not establish a whole-frame match. Color faces use the app's face-normal shade replicated at each vertex and a float inverse-depth overlay against the textured frame; POD uses its shared coverage mask and depth buckets. Negative-width Gouraud spans get zeroes for the untraced memory below the reciprocal table. The default standin renderer still shows generic gray for page-less faces. The screenshot's apparent roof gaps remain unknown: there is no POD capture of the same track and camera to compare against.
Both Un-POD renderers draw the track's cloud layer, horizon and world in that order. world::build keeps the cloud words, indexed page and palette, normalized track light, horizon band and BkGround flag. apps/unpod/src/sky.rs builds cloud spans over the blue clear, places the sun marker, overlays the transparent horizon with draw_horizon FUN_00495804 and span_horizon FUN_004d81ba, and retains cloud scroll and distance state across frames. This is inferred wiring from draw_frame_views FUN_0049af38; it has not been compared as a whole frame. The app still uses a chase-camera stand-in and an empty world coverage mask, skips a cloud frame on the measured divide fault, and supports only indexed .BL3 clouds. The pod-render path draws the F0/RGB565, dither-off flare after world faces if the marker survives. The stand-in path does not draw a flare; split-screen and 8/32-bit sun offsets and dither remain not ported.
Open
- What marks the coverage mask besides the polygon filler, and what writes the 0x0820 pixels over the player's car in the recorded frame.
- The flat-colour and ordinary Gouraud drawers (
FUN_00492f9c,FUN_004d4e60,FUN_004d4ea8,FUN_00492fec,FUN_004d4f00) are connected to thepod-renderapp path. Their per-vertex lights remain the app's stand-in face shade; the color pass's depth overlay is not POD's shared coverage-mask pass. Negative-width shade rows use zeroes for untraced memory before the reciprocal table. The defaultstandinrenderer still uses generic gray for these faces. The affine textured drawers for the fan filler (FUN_00493070,FUN_004d6af6,FUN_004d6cee) are measured and ported inpod_render::span::gradient, with 240 full-frame fixtures intests/gradient.rs. The app now preserves source face flags and dispatches both measured textured fill paths. Its split triangles and float face loop are deviations from POD; compare a matched track, camera and frame before attributing the roof gaps to this change. - The triangle face loop
FUN_00458cc8; lit vertex lightsFUN_004262c8andFUN_00425cccwithFUN_004d4770andFUN_004d465d; theFUN_004a888ccall for faces with+0x35 & 0x20; readers of the reciprocal z and step tables; where camera z becomes the filler's depth term; how the camera is placed each frame; andvector_length_x87at 64-bit precision. - The cloud layer (
draw_cloud_layerFUN_0048c8f4,cloud_clip_edgeFUN_0048dd80,draw_cloud_spansFUN_004950d4) is measured and ported for the 16-bit single view (pod_render::sky::cloud,tests/cloud_layer.rs) and wired into the app backgrounds. Its frame wiring uses the stand-in camera and has no whole-frame comparison; kernelspan_cloudFUN_004d6900is ported.fill_cloud_spansFUN_00494ee8andfill_cloud_triangleFUN_00494c00are measured and ported (measure/render_cloud_fill.py,tests/cloud_fill.rs). The cloud layer measurement replays 787 layer cases and 1,317 direct clip cases (measure/render_cloud_layer.py). The sun marker and flare (FUN_0048c100,FUN_0048c248), whiten and dither drawers, and whiten table rows 0..31 are ported and replay 94 synthetic cases (pod_render::sun;tests/sun_flare.rs). The app places the marker before the horizon and draws the F0/RGB565, dither-off flare after world faces inpod-render; the stand-in flare, split-screen path, 8/32-bit offsets and dithered flare remain not ported. sky::draw_skyremains a standalone helper using an affine panorama sampler; the app's race background now builds measured horizon lists and draws them with the measureddraw_horizon/span_horizonpath. A whole-frame sky comparison remains open.apps/unpod/src/render_pod.rsstill supplies its own projection and clips the scene before callingpod-render; wiring the measured transform and clipper into the app remains open.- The missing roofs in the stand-in screenshot: identify the track and compare the same view with POD before deciding whether geometry or rendering drops the roofs.
- What writes the coverage mask before
clear_uncovered, and the remaining overlays and car render path, as checked against a recorded frame.