The second decomp round (flood bookkeeping/propagation, view-clip support, landscape visibility) is archived Ghidra-arbitrated - it caught a load-bearing BN inversion (InsCellTodoList pops NEAREST-first, which is what makes the draw list far-to-near) and three more traps (the 192m elided constant, the min/max double positional swap, the copy_view cross order - the walk doc section 6 is corrected). WalkVisibilityMath ports get_pt_limit @0x0054b840, get_clip_height @0x0054cff0, corner/block_plane_check @0x0054b930/@0x0054d060, block_check @0x0054dc50, and viewconeCheck @0x0054c250 with retail boundary semantics (strict cull, inclusive partial, touch-out=Outside / touch-in=EntirelyInside) under 23 focused tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
15 KiB
Markdown
277 lines
15 KiB
Markdown
# FW0 — the retail frame walk, port-ready pseudocode model (2026-08-30)
|
||
|
||
The distilled model FW1 ports from. Sources: the five decomp reads in
|
||
[`2026-08-30-fw-walk-pseudocode-appendix.md`](2026-08-30-fw-walk-pseudocode-appendix.md)
|
||
(BN pseudo-C cross-checked against the Ghidra patchmem decomp wherever
|
||
x87-flag mush demanded it), reconciled against the live traces in
|
||
[`2026-08-30-fw-walk-oracle/`](2026-08-30-fw-walk-oracle/README.md).
|
||
Every function below carries its named symbol + address; the appendix has
|
||
the full extractions with per-function gotchas.
|
||
|
||
## 1. The frame root — `SmartBox::RenderNormalMode` @0x00453aa0
|
||
|
||
Per frame (via `SmartBox::Draw` → `DrawNoBlit` @0x00454c20, which first
|
||
runs `update_viewer` and skips rendering entirely when `viewer_cell` is
|
||
null):
|
||
|
||
```
|
||
if ((viewer.objcell_id & 0xFFFF) < 0x100) { // CAMERA cell is outdoor
|
||
LScape::update_viewpoint(lscape, viewer.objcell_id);
|
||
Render::update_viewpoint(&viewer);
|
||
Render::set_default_view(); // full-screen view
|
||
Render::useSunlightSet(1);
|
||
LScape::draw(lscape); // → §3
|
||
} else { // camera in an EnvCell
|
||
if (viewer_cell->seen_outside)
|
||
LScape::update_viewpoint(lscape, Position::get_outside_cell_id(&viewer));
|
||
Render::update_viewpoint(&viewer);
|
||
render_device->DrawInside(viewer_cell); // → PView::DrawInside, §5
|
||
}
|
||
D3DPolyRender::FlushAlphaList(0f);
|
||
```
|
||
|
||
`SmartBox::update_viewer` @0x00453ce0 resolves the camera pose and cell:
|
||
a `CTransition` sphere sweep (the global `viewer_sphere`) from a pivot on
|
||
the player (CameraManager pivot part + offset) toward the camera's sought
|
||
position; `t->sphere_path.curr_cell` becomes `viewer_cell`. Fallbacks:
|
||
`AdjustPosition` on the raw sought position, else camera at the player
|
||
with `viewer_cell = null` (frame skipped). **The frame roots at the
|
||
CAMERA's cell** — proven live by the porch-cam trace.
|
||
|
||
## 2. The invisible-panel primitive — `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90
|
||
|
||
The single primitive behind both "punch" and "seal": draws one portal
|
||
polygon as an untextured CULL_NONE triangle fan, **depth-test ALWAYS,
|
||
z-write ON, color alpha 0** (invisible under SRCALPHA/INVSRCALPHA).
|
||
Byte-verified in the raw pseudo-C: `SetDepthBufferMode(DEPTHTEST_ALWAYS,
|
||
(mode>>2)&1)` @0059be02; forced Z `0.999998987f` @0059bf4b.
|
||
|
||
- `flag=1` (mode global `maxZ1`=7): every vertex Z forced to
|
||
**0.999998987 (far plane)** — the **PUNCH**: re-opens the aperture's
|
||
depth so look-in content can draw into it.
|
||
- `flag=0` (mode `maxZ2`=6): real projected Z — the **SEAL**: writes the
|
||
portal surface's own depth at the aperture. Only seals increment
|
||
`portalsDrawnCount` (the deferred z-clear trigger).
|
||
- Trivial reject: polygon wholly outside a ±12.0 local-XY box (the
|
||
24 m cell); the reject-loop polarity is an FP-status idiom (`test
|
||
ah,0x44`) BN could not lift — **re-verify polarity at FW1 against the
|
||
binary bytes** before porting.
|
||
- Debug: raising the mode's alpha bit (via the maxZ1/maxZ2 globals,
|
||
.data @0x820e18/0x820e14) renders the panels visibly in cycling colors.
|
||
- Goes through `DrawPrimitiveUP`, gated by the registry bool
|
||
`s_bAllowDrawPrimitiveUP` (default 1).
|
||
- Restores NO state — callers rely on backup/restore_curr_state.
|
||
|
||
**This resolves the campaign's panel question:** the "seal panels" are
|
||
not mesh subsets (those authored Translucency-1.0 subsets stay
|
||
never-drawn); they are the buildings'/cells' PORTAL POLYGONS drawn
|
||
depth-only by this primitive. AD-117's stamps unknowingly re-invented a
|
||
real retail mechanism at the wrong site with the wrong geometry.
|
||
|
||
## 3. The outdoor walk — `LScape::draw` @0x00506330
|
||
|
||
```
|
||
GameSky::Draw(sky, 0) // dome
|
||
LScape::draw_check_blocks(this) // per-view visibility union (below)
|
||
for i = mid_width² - 1 .. 0: // block_draw_list built near-to-far →
|
||
blk = block_draw_list[i] // backwards walk = FAR-TO-NEAR
|
||
if (blk && blk->in_view) render_device->DrawBlock(blk)
|
||
if (weather) GameSky::Draw(sky, 1)
|
||
```
|
||
|
||
- Grid: `mid_width`=11 × 11 blocks (`mid_radius`=5); block side 192 m
|
||
(FPU-elided in BN — pinned by the explicit 24 m cell pitch).
|
||
- `get_block_order` @0x00504c50: viewer's block first, then concentric
|
||
rings (8 quadrant-symmetric slots per step from static coefficient
|
||
tables); rebuild only when the viewer crosses a LANDBLOCK boundary.
|
||
- Block frames are **viewer-relative**: origin
|
||
`((row−viewer_b_xoff)·192, (col−viewer_b_yoff)·192, 0)` — the
|
||
landscape renders in viewer-block-local space for float precision.
|
||
- `draw_check_blocks` @0x00505f80: clears every block/cell `in_view`,
|
||
then FOR EACH VIEW of `Render::PortalList` (outdoor: the default view;
|
||
interior: the exit views) unions visibility via clip-height interval
|
||
grids (`get_clip_height` corners → `block_check` vs max/min_zval);
|
||
`landcell_check` @0x005050a0 refines per-cell (non-8×8 far blocks:
|
||
all cells PARTIAL; ENTIRELY_INSIDE blocks: all cells 2; else per-cell
|
||
corner grids at 24 m pitch). A cell marked by an earlier view is never
|
||
downgraded.
|
||
- `CLandBlock::calc_sq_draw_order` @0x0052f4a0: per-block cell order,
|
||
closest cell (picked by the 9-case compass direction switch) written
|
||
LAST, rings filled backwards → forward iteration = far-to-near.
|
||
Early-out compares only `closest.x/y`, not `dir` (keep this quirk).
|
||
|
||
Per block, `RenderDeviceD3D::DrawBlock` @0x005a17c0: pass 1 updates
|
||
in-view cells' shadow objects + insertion-sorts shadow parts by depth;
|
||
pass 2 per cell (far-to-near): terrain (`DrawLandCell` — detail texture
|
||
only on full 8×8 blocks) then `DrawSortCell` @0x0059f140:
|
||
|
||
```
|
||
if (cell->building) render_device->DrawBuilding(cell->building) // §4
|
||
render_device->DrawObjCell(cell) // scenery/statics/dynamics via sorted shadow parts → CPhysicsPart::Draw(part, 0)
|
||
```
|
||
|
||
Mid-walk alpha pressure valve: `FlushAlphaList(flush)` with the global
|
||
`flush`=0.75 (flushes only above ~2250 queued alpha meshes).
|
||
|
||
## 4. Buildings — `RenderDeviceD3D::DrawBuilding` @0x0059f2a0
|
||
|
||
```
|
||
outdoor_pview->outdoor_portal_list = b->portals; // ALWAYS
|
||
CPhysicsPart::UpdateViewerDistance(part0); // degrade level
|
||
if (part0->gfxobj[deg_level] == 0) return; // degraded out → whole building skipped
|
||
CBuildingObj::curr_pos = &b->m_position; // frame-restore anchor for the portal pass
|
||
set building detail-surface state; FlushAlphaList(0f);
|
||
CPhysicsPart::Draw(part0, 1); // PORTAL pass
|
||
ObjBuildingOrBuildingPart = 1;
|
||
CPhysicsPart::Draw(part0, 0); // SHELL pass (normal mesh; per-frame dedupe applies)
|
||
ObjBuildingOrBuildingPart = 0;
|
||
```
|
||
|
||
The portal pass, through `DrawMesh` @0x005a0860 (per portal view,
|
||
`building_view`-gated; portal-flagged draws run even when viewconeCheck
|
||
says OUTSIDE) → `DrawMeshInternal` @0x0059f360, walks the building's
|
||
drawing-BSP **twice** (`BSPTREE::build_draw_portals_only` — portal nodes
|
||
visited far-to-near, plane epsilon 0.0002):
|
||
|
||
- **Pass 1 — punch.** Each in_portal → `PView::DrawPortal` @0x005a5ab0 →
|
||
`ConstructView(CBldPortal)` @0x005a59a0: viewer sidedness must match
|
||
the authored `portal_side` (IN_PLANE within ±0.0002 fails BOTH gates);
|
||
`GetClip` must leave ≥3 points; the destination EnvCell must be
|
||
currently Visible (`CEnvCell::GetVisible` — not loaded ⇒ silently no
|
||
punch, no fallback). On success: `DrawPortalPolyInternal(poly, 1)` —
|
||
the far-Z punch — and the clipped view is pushed onto the destination
|
||
cell's top `portal_view` via `Render::copy_view`.
|
||
- **Pass 2 — look-in.** Same walk/gates; on success recurses
|
||
`ConstructView(CEnvCell)` (the §5 flood seeded through
|
||
`other_portal_id`) and calls `PView::DrawCells` — the trace's
|
||
`DC ov=0` punches: interior cells + objects drawn into the punched
|
||
aperture (no landscape, no clear, no sealing on this path).
|
||
- Push/pop asymmetry (MUST preserve): `ConstructView(CBldPortal)` pops
|
||
the building frame only on SUCCESS (before recursing); on failure the
|
||
frame stays pushed and `DrawPortal` compensates by re-pushing
|
||
`CBuildingObj::curr_pos` only on success.
|
||
- `DrawCells` bumps `m_nFrameStamp`, re-arming the per-frame part dedupe
|
||
so objects already drawn outdoors can draw again inside a look-in.
|
||
- DEAD in 2013 (do not port): `DrawBuildingLeaf` + `curr_leaf_cells`
|
||
(pre-EnvCell interior model); `PView::DrawPortal`'s pass==3
|
||
seal-on-fail branch (pass only ever ∈ {1,2}).
|
||
|
||
## 5. Interior frames — `PView::DrawInside` @0x005a5860
|
||
|
||
```
|
||
CEnvCell::curr_view_push(cell); // num_view++ (0x48-byte lazy slot, counters reset)
|
||
PView::add_views(this, cell->num_stabs, cell->stab_list); // push a view slot on every visible stab cell
|
||
Render::positionPush(3, identity Position stamped with cell DID);
|
||
Render::copy_view(cell->top_view, null, 4); // root view = full viewport quad
|
||
PView::ConstructView(this, cell, 0xffff); // the flood (below)
|
||
PView::DrawCells(this, 0); // dead arg
|
||
Render::positionPop(); remove_views(...); cell->num_view -= 1;
|
||
```
|
||
|
||
`ConstructView(CEnvCell)` @0x005a57b0 — the flood: zero
|
||
`outside_view.view_count`, bump `master_timestamp`, reset todo/draw
|
||
lists; seed `InitCell(cell, 0xffff)` + `InsCellTodoList(cell, 0)`;
|
||
then pop-from-END: append cell to `cell_draw_list` (grow +30), mark top
|
||
view `cell_view_done`, `ClipPortals` → `AddViewToPortals` (clips each
|
||
portal against the cell's current view and enqueues neighbors with their
|
||
clipped views; portals leading outdoors raise `outside_view`).
|
||
|
||
`PView::DrawCells` @0x005a4840 — the draw, all passes far-to-near
|
||
(`cell_draw_list` walked from the end):
|
||
|
||
```
|
||
if (outside_view.view_count > 0) { // 'ov' in the traces (field, not the dead arg)
|
||
Render::PortalList = &outside_view;
|
||
LScape::draw(lscape); // landscape THROUGH the exit views (§3)
|
||
FlushAlphaList(0f); m_nFrameStamp += 1;
|
||
if (forceClear || portalsDrawnCount != 0) // count reset only on the !forceClear arm
|
||
Clear(flag 4 /*z-buffer*/, black, z=1.0); // deferred clear triggered by earlier SEALS
|
||
for cells far-to-near, per view of the cell's top portal_view:
|
||
for each portal with other_cell_id == 0xFFFFFFFF: // exits to the landscape
|
||
DrawPortalPolyInternal(portal.poly, 0); // SEAL @ own depth (verified @0059a49b7)
|
||
}
|
||
useSunlightSet(0); restore_all_lighting();
|
||
for cells far-to-near, per view: setup_view; DrawEnvCell(cell); // BSP geometry
|
||
for cells far-to-near: PortalList = cell top view; DrawObjCellForDummies(cell); // objects
|
||
```
|
||
|
||
Degenerate guard NOT to port: `num_view==0` would loop 65535 views —
|
||
unreachable (every listed cell was `curr_view_push`ed).
|
||
|
||
## 6. View machinery
|
||
|
||
- `Render::set_view` @0x0054d0e0 — pure CPU global install: the active
|
||
view's edge count, in-mask (`npnts+1` bits: edges + the CY plane),
|
||
vertex/plane base, screen bounds. No GPU state.
|
||
- `Render::copy_view` @0x0054dfc0 — appends ONE view: perspective-divided
|
||
screen points (drop near-duplicates/collinear within ~1 px; <3
|
||
survivors ⇒ reject; cap 31), bounds, and per-edge WORLD-space planes
|
||
(`N = normalize(cross(ray[k+1], ray[k]))` — NEXT × CURRENT, corrected
|
||
2026-08-30 by the flood-read appendix against Ghidra; an earlier
|
||
revision of this line had the operands reversed —
|
||
`d = −dot(N, viewpoint)`). `copy_view(dest, null, n)` = the
|
||
full-viewport root quad (count ignored). Full extraction:
|
||
`2026-08-30-fw-flood-pseudocode-appendix.md`.
|
||
- `PView::GetClip` @0x005a4320 — projects the portal polygon
|
||
(`xformStart`), reverses winding for NEGATIVE side, and (do_clip)
|
||
clips via `ACRender::polyClipFinish` against the installed view. ≤32
|
||
verts assumed. The only CPU polygon clip in the frame.
|
||
- `Render::viewconeCheck` @0x0054c250 — sphere vs the CY plane + the
|
||
active view's edge planes, in viewer-block space. Cull strict
|
||
(`d < −r`); partial inclusive (`d ≤ r`); publishes
|
||
`local_object_center/radius` ALWAYS (even when OUTSIDE). BN's version
|
||
of this body is unusable (inverted flags) — the model here is
|
||
Ghidra-verified.
|
||
|
||
## 7. Constants and anchors
|
||
|
||
| Item | Value |
|
||
|---|---|
|
||
| Outdoor cell test | `(objcell_id & 0xFFFF) < 0x100` |
|
||
| Plane/side epsilon `F_EPSILON` | 0.000199999995f (0x3951B717) |
|
||
| Punch far-Z | 0.999998987f |
|
||
| Panel depth mode | test ALWAYS, write ON (mode bit2), alpha 0 |
|
||
| Panel local-XY reject box | ±12.0 |
|
||
| Landscape grid | mid_width 11, mid_radius 5, block 192 m, cell 24 m |
|
||
| Alpha pressure valve | global `flush` = 0.75 (~2250 meshes) |
|
||
| copy_view vertex cap | 31 (0x1F) |
|
||
| Flood list growth | +30 (0x1E) |
|
||
| BoundingType | 0 OUTSIDE, 1 PARTIALLY_INSIDE, 2 ENTIRELY_INSIDE |
|
||
| Sidedness | 0 POSITIVE, 1 NEGATIVE, 2 IN_PLANE (always rejects) |
|
||
| Exit-portal sentinel | `other_cell_id == 0xFFFFFFFF` (cell), `0xFFFF` (no-through seed) |
|
||
|
||
Struct anchors (acclient.h): `PView` {outside_view@0 (view_count@+0x38),
|
||
draw_landscape, outdoor_portal_list, cell_draw_list@+0x50,
|
||
cell_draw_num@+0x60, cell_todo_list, cell_todo_num, lscape@+0x78};
|
||
`CEnvCell.m_DID`@+0x28, `num_view`@+0x134, `portal_view`@+0x138;
|
||
`CCellPortal` stride 0x18 {other_cell_id, other_cell_ptr, portal@+8,
|
||
portal_side, other_portal_id, exact_match}; `CBldPortal` {portal_side,
|
||
other_cell_id, other_portal_id, exact_match, num_stabs, stab_list};
|
||
`CLandBlock.in_view`@+0xFC, `CLandCell` stride 0x108 (in_view@+0x104).
|
||
|
||
## 8. What the traces + code jointly prove about #456
|
||
|
||
Outdoor frames draw EVERYTHING distance-eligible — the far building
|
||
first (far-to-near) — and correctness is pure depth: nearer terrain,
|
||
shells, punches, and look-in cell geometry bury the vista. Interior
|
||
frames never roster the far building (exit-view cone culls it) and SEAL
|
||
their exit apertures at own depth after drawing the landscape through
|
||
them, so nothing drawn later can leak into the aperture. There is no
|
||
hiding mechanism to invent; FW3/FW4 reproducing this walk resolves #456
|
||
wholesale.
|
||
|
||
## 9. Verification status
|
||
|
||
Byte-verified against the raw pseudo-C by hand: the seal loop
|
||
(`other_cell_id==0xffffffff → DrawPortalPolyInternal(poly,0)` @0059a49af),
|
||
the deferred z-clear (@005a48a9), `DEPTHTEST_ALWAYS` + z-write-from-mode
|
||
(@0059be02), punch Z 0.999998987f (@0059bf4b), the fan submit (@0059bf84).
|
||
Ghidra-arbitrated where BN mush was flagged: the rooting condition, the
|
||
`viewconeCheck` comparisons, the ConstructView sidedness decode, the
|
||
DrawCells dead argument. **Re-verify at FW1 before relying on:** the ±12
|
||
reject polarity in `DrawPortalPolyInternal` (FP-status idiom), the
|
||
`DrawBlock` alpha-flush comparison polarity, and `InsCellTodoList`'s
|
||
insert position (LIFO pop order depends on it — port it from its body
|
||
@0x005a4f50, not from assumption). Resolved non-issue: the trace's `ov`
|
||
is the FIELD `outside_view.view_count` (probe read PView+0x38), not
|
||
`DrawCells`' dead argument — reports 2 and 5 agree once combined.
|