Physics units: rays and the world¶
Verified against: SLUS_212.15 (NTSC-U, SHA1 e9cb2cc49aa046b9e494313dce2f5038ed17b2f4). Static reading in Ghidra
(2026-10-06, the whole of Physics/ on 2026-10-07); the game under PCSX2 2.9.94 (PINE, hooks and pcsx2 record, in
level99, 2026-10-07). The exact specifications of
Reimplementing Physics, RayCast and World were read in the disassembly on 2026-10-12.
Part of Reimplementing Physics, RayCast and World: the exact specifications of rays against bodies, the physics world, the world manager's ray, the level file and object, the world manager and the streamed world object. The plan, the order of the units and their dependencies are on that page; the subsystem itself is Physics.
Unit 14: Rays against bodies¶
What it is. The ray the AI's line tests cast against the bodies of the world (the level mesh is a separate ray, Collision): the candidate bodies, the ray table's box and capsule tests, and the nearest-hit rule.
- Coney:
src/physics/rays.*, beside Unit 7's candidate query. The AI's line tests (Goal_LineTest0x002e7608,Shot_CanHitHuman, the stationary shooter's two,BossTactic_Process) have no Coney counterpart yet; they cast this ray and the mesh ray and keep the nearer (AI goals). - Tests: Test ideas: the slab entry of a rotated box, a ray starting inside a box (fraction 0) and inside a capsule (its exit), the rounded capsule top, the straight-down capsule quirk, the filters and the nearest hit.
5 functions, 2436 bytes (0 tagged).
| Address | Name | Size |
|---|---|---|
0x00349488 |
Ray_ClipSlab |
212 |
0x00349560 |
Ray_OrientedBox |
864 |
0x003459f0 |
PhysicsRay_Capsule |
412 |
0x00345b90 |
PhysicsRay_Box |
388 |
0x0033eee0 |
IPhysics_RayCastBodies |
560 |
The capsule kernel itself, Sweep_SphereCapsule (0x00349a60), belongs to Unit 11; what
it does with a
ray is restated below so this unit stands alone. All claims confirmed (code) at the address of
the section unless a line says otherwise (read in the disassembly, 2026-10-12). Notation as in Unit
3.
The ray and the hit list¶
Ray (16-byte aligned, the caller's): +0x00 start S (w = 1), +0x10 direction d. The caller passes a length
len in f12 beside it. Every function here uses the segment from S to S + D, with D = d × len (xyz; the
product keeps d's w). Fractions are of D: the point at fraction f is S + f·D, its distance from S is f·len·|d|. The
AI's callers pass a unit d, so the distance is f·len (inferred from the callers' use, not checked for each one).
Hit list (a stack record the ray kernels fill; Ray_OrientedBox and Sweep_SphereCapsule share its layout):
+0x00 count (u32, the caller sets 0), +0x04 + 4i fraction i, +0x10 + 16i a normal for hit i. The kernels write
the constant (0, 0, 0, 1) (0x005116c0) as every normal: no ray here produces a surface normal. At most two
entries are written. Confirmed (code) at 0x003497e4-0x00349890 and 0x00349d1c-0x0034a2a4.
Contact fields written (the 0xc0 contact): the shape functions write +0x80 (the fraction)
and
+0xb0 = 0 on a hit and nothing on a miss; IPhysics_RayCastBodies then writes +0x40 (owner), +0x44 (shape)
and +0x80 (the nearest fraction). Nothing else: no point (+0x90) and no normal (+0x50). The callers compute
those themselves (Goal_LineTest writes +0x90 and +0xa0, AI goals).
Ray_ClipSlab¶
0x00349488, 0xd4 bytes. bool Ray_ClipSlab(float den /*f12*/, float num /*f13*/, float* t0 /*a0*/, float* t1
/*a1*/). One half-space of a box: the points at parameter t with den·t ≥ num are inside. It narrows the interval
[t0, t1] to them, or returns false when nothing of it is left:
| Case | Test (in this order) | Result |
|---|---|---|
| den > 0 (entering) | den·t1 < num | false (the half-space starts after t1) |
| else den·t0 < num | t0 = num / den; true | |
| else | true, unchanged | |
| den < 0 (leaving) | den·t0 < num | false (the half-space ended before t0) |
| else den·t1 < num | t1 = num / den; true | |
| else | true, unchanged | |
| den = 0 (parallel) | num ≤ 0 | true (the line lies inside), else false |
No epsilon anywhere: the parallel case is den exactly 0.0 (c.lt.S both ways, 0x00349490, 0x003494ec), and
touching counts as inside (all tests are strict < for rejection). The multiplications are done, never a division
before the test, so den = 0 never divides. Because a den > 0 clip rejects when the new t0 would pass t1 and a den < 0
clip rejects when the new t1 would fall under t0, the interval stays ordered: t0 ≤ t1 after any sequence of clips.
Ray_OrientedBox¶
0x00349560, 0x360 bytes. bool Ray_OrientedBox(HitList* out /*a0*/, const Segment* ray /*a1*/, const BoxFrame* box
/*a2*/): ray is {S at +0x00, D at +0x10} (already scaled); box is a 0x50-byte frame {+0x00 A0, +0x10
A1, +0x20 A2 (unit axes), +0x30 P (centre), +0x40, +0x44, +0x48 half extents h0, h1, h2}, which is a box
shape's +0x50-+0x80 followed by its +0x40 (Shapes).
- Local coordinates (VU0 dot products, x+y then +z): r = S − P (xyz, w = 1); d_i = r·A_i and m_i = D·A_i for
i = 0, 1, 2 (
0x003495b0-0x00349690). - Clip with t0 = 0, t1 = 1, six calls in this order, stopping at the first false (
0x003496bc-0x00349770): for i = 0, 1, 2:ClipSlab(m_i, −d_i − h_i)(the face at −h_i: d_i + m_i·t ≥ −h_i) thenClipSlab(−m_i, d_i − h_i)(the face at +h_i: d_i + m_i·t ≤ h_i). Any false: return false,outuntouched. - No hit when nothing was clipped: if t0 = 0.0 and t1 = 1.0 exactly, return false (
0x0034978c-0x003497b0). This is the whole segment inside the box, and also a zero D (every den is 0, so every clip is the parallel case). - Otherwise: if 0 ≤ t0 ≤ 1, append t0. Then if 0 ≤ t1 ≤ 1 and t0 < t1, append t1. Return true (even if nothing was appended; with t0 ≤ t1 inside [0, 1] after step 2 both conditions always hold except t0 = t1).
So a segment that starts inside the box and leaves it gets [0, exit]; one that starts outside and ends inside gets
[entry, 1]; one that only touches a face, edge or corner gets [t] once (t0 = t1). Confirmed (code) at 0x00349560.
PhysicsRay_Box¶
0x00345b90, 0x184 bytes; ray table +0x108 entry for type 1. Also called by ObjectRender_Draw (0x0017fd78).
bool PhysicsRay_Box(float len /*f12*/, Contact* c /*a0*/, const Ray* ray /*a1*/, const BoxShape* box /*a2*/).
- D = ray.d × len (xyz;
vmulx.xyzkeeps d's w). Copies S and D, and the box's A0, A1, A2, P (+0x50-+0x80) and half extents (+0x40) into a frame on its stack (0x00345bb8-0x00345c04). The box's current pose is used. Ray_OrientedBoxinto a hit list with count 0. False: return 0.- Filter: walk the entries from the last to the first; an entry whose fraction is < 0 or > 1 is removed (the later
entries shift down). With
Ray_OrientedBoxevery entry is already in [0, 1], so nothing is removed here. - Count 0: return 0. Else
c+0xb0= 0,c+0x80= entry 0's fraction, return 1.
What it answers: the entry fraction of the segment into the box; 0 when the ray starts inside the box (or on
its surface) and leaves it; a hit at fraction 1.0 when the segment ends exactly on a face; no hit when the whole
segment lies inside. Confirmed (code) at 0x00345b90.
PhysicsRay_Capsule¶
0x003459f0, 0x19c bytes; ray table entry for type 3. bool PhysicsRay_Capsule(float len /*f12*/, Contact* c
/*a0*/, const Ray* ray /*a1*/, const CapsuleShape* cap /*a2*/).
- D = ray.d × len (
0x00345a38). The capsule argument for the kernel, built on its stack (0x00345a18-0x00345a84): base P = shape+0x50; axis A = (P with z += height) − P, xyz, w = 1, so A = (0, 0, h') with h' = (Pz + h) − Pz computed in floats (h = shape+0x44; h' can differ from h by rounding when Pz is large); radius R = shape+0x40. No radius is given for the ray: it is a line. - The kernel is
Sweep_SphereCapsule(0x00349a60), the same one the sphere-capsule sweep uses, notRay_InfiniteCylinder(0x0034d938, the mesh's edge cylinder). Confirmed (code): thejal 0x00349a60at0x00345a88. - A false answer: return 0. Else the same filter as the box (drop fractions outside [0, 1], last to first), then count
0: return 0; else
c+0xb0= 0,c+0x80= entry 0 (the first one stored, which is not always the smallest, below), return 1.
The capsule here is a true capsule: an upright cylinder of radius R from Pz to Pz + h' with a hemisphere of radius R on each end, so a ray reaches R above the top and R below the base. (The overlap and sweep tests treat the same shape differently, Shapes.)
What Sweep_SphereCapsule does with a ray¶
Sweep_SphereCapsule(HitList* out /*a0*/, const Segment* seg /*a1: S, D*/, const Capsule* cap /*a2: P, A, R*/),
0x00349a60, returns true when it stored at least one root. Restated from the disassembly for the ray case
(confirmed (code) at the addresses given):
- Frame (
0x00349a98-0x00349b64). L = |A|, U = A / L. A perpendicular basis E1, E2 from U (Vec_MakePerpendicularBasis0x00349180, Unit 1); for the upright capsule only the horizontal plane matters and every result below is independent of which E1, E2 it picks. Local D' = (D·E1, D·E2, D·U); n = |D'| (= |D|); invn = 1 / n; u = D' × invn, the unit direction (0x00349bac-0x00349be0). Start: r = S − P, s = (r·E1, r·E2, r·U); s3 is the start's height above the base. - Nearly vertical, or too short (
0x00349c1c-0x00349c90): if |u3| ≥ 0.999999 (0x3f7fffef, 0.99999899) or n < 1e-6 (0x358637bd), take the parallel branch: q² = R² − s1² − s2² and k = D·A (the world dot product, unscaled). - k < 0 (going down) and q² ≥ 0: store (s3 + q)·invn then (L − s3 + q)·invn; return true.
- k > 0 (going up) and q² ≥ 0: store −(s3 + q)·invn then (L − s3 + q)·invn; return true.
- otherwise (q² < 0, or k = 0, which covers a zero D): return false.
Going up, the two are the entry through the bottom (height −q) and the exit through the top (L + q): correct.
Going down they are wrong: the first is the exit through the bottom, and the second is the entry through the top
with its sign flipped (the true entry distance is s3 − L − q). So a ray coming straight down onto a capsule from
above reports, after the filter, the fraction where it leaves the bottom, or no hit at all when that point is
past the end of the segment. Confirmed (code) at 0x00349cd0-0x00349d74.
3. Otherwise, the side (0x00349e2c-0x00349fa0): b = s1·u1 + s2·u2, c = s1² + s2² − R², a = u1² + u2²,
disc = b² − a·c.
- disc < 0: return false (the line misses the infinite cylinder, so it misses the caps too).
- disc > 0: for t = (−b − √disc) / a, then t = (−b + √disc) / a: the height z = s3 + t·u3; if 0 ≤ z ≤ L store t·invn.
With two stored, return true. Else go to the caps.
- disc = 0 exactly: t = −b / a; if 0 ≤ z ≤ L store it and return true; else go to the caps.
4. The bottom hemisphere (centre P; 0x0034a018-0x0034a174): c' = c + s3² (= |r|² − R²), b' = b + s3·u3
(= r·u), disc' = b'² − c'. If disc' > 0: for t = −b' − √disc', then −b' + √disc': store t·invn when z = s3 + t·u3
≤ 0, returning true as soon as two are stored. If disc' = 0: t = −b', the same test. disc' < 0: nothing.
5. The top hemisphere (centre P + L·U; 0x0034a178-0x0034a2a4): b'' = b' − u3·L, c'' = c' + L·(L − 2·s3);
the same roots, stored when z ≥ L, returning true at two.
6. Return count ≠ 0.
The roots are in stored order, not sorted: side near, side far, bottom near, bottom far, top near, top far, the
first two that pass. Roots behind the start (t < 0) are stored too; the caller's filter removes them. Consequences
for PhysicsRay_Capsule, which takes entry 0:
- A ray starting inside the capsule reports its exit (the near root is negative and filtered out), unlike the box, which reports 0.
- A slanted ray that enters through the top hemisphere and leaves through the side reports the side exit: the side's far root is stored first (its near root lies above L), then the top's near root. Coming in from the side and leaving through a cap gives entry first, which is right.
- The z tests are inclusive at both ends, so a root exactly at the base or top height can be stored by the side and again by a cap.
The arithmetic is floats in this order (EE rounding, no NaN; Floating point); at disc = 0 exactly the two branches differ, so tests should avoid exact tangency.
IPhysics_RayCastBodies¶
0x0033eee0, 0x230 bytes. bool IPhysics_RayCastBodies(float len /*f12*/, IPhysics* phys /*a0*/, Contact* out
/*a1*/, const Ray* ray /*a2*/, u32 typeMask /*a3*/, Owner* ignored /*t0*/). Every caller passes typeMask =
0xffffffff (lui + ori 0xffff before each jal, 0x002e765c, 0x002e78c4, 0x002f01c4, 0x002f05dc,
0x00308ed8) and its own human as ignored; BossTactic_Process casts 25 m (0x41c80000).
- The box. E = S + d × len (w = 1). A body node on its stack is initialised (
PhysicsNode_Init0x00341158) and given min = componentwise min(S, E), max = max(S, E) (vmini/vmax,0x0033efb0-0x0033efe0). No margin. - Candidates (
IPhysics_QueryBox0x0033feb0, Queries): start body 0 (so the walk runs forwards from the list head), ignored ownerignored, type masktypeMask, flag mask 0x2000 (RAYCASTTARGET, layers). QueryBox keeps a body whenbody+0x44 & typeMask≠ 0, so a type mask of 0 finds nothing; and whenbody+0x40 & 0x2000≠ 0; overlap tests are inclusive. Up to 512 bodies fit the stack array (sp+0x20, 0x800 bytes); QueryBox does not check that bound. - Shapes. For each candidate in list order, its shapes from body vtable
+0x28(called with (2, 4, 0), which both implementations ignore:PhysicsBody_GetShapes0x00341ae0and the human body's0x003420c8return+0x30), following each shape's+0x00. A shape is tested when its flag0x1(enabled) is set and the ray table has an entry for its type (phys+0x108 + 4·type: box and capsule only, dispatch). A human body's chain is its capsule alone: the ten bone shapes hang from+0x80, not+0x30, so a ray never meets them. - Nearest. best = 1e9 (
0x4e6e6b28). The shape function is called with (len,out, ray, shape); on a hit it has writtenout+0x80; if that is strictly less than best, best, the shape and the body are kept. Ties keep the earlier one: list order (x-sorted by AABB min), then chain order. - Answer. With a kept body:
out+0x44= the shape,out+0x80= best,out+0x40= the body's owner (+0x34); return true. None: return false. On a miss nothing ofoutis written; on a hitout+0x80andout+0xb0may also have been written by shapes that were not the nearest (+0x80is rewritten at the end,+0xb0is 0 either way).
So the answer is the owner, shape and fraction of the nearest entry (by the per-shape rules above, including the
capsule's quirks) among enabled box and capsule shapes of bodies with RAYCASTTARGET. In level99 those are the
humans' capsules (body flags 0x2203f etc.), the cars' boxes (0x2211a) and the objects whose type sets the layer;
not glass panes (0x7a) (confirmed (runtime), the flags in layers).
Test ideas¶
Values computed from this spec (Python, doubles; compare with a tolerance of 1e-5). Box B: centre (0, 0, 1), axes identity, half (1, 0.5, 1).
- Through a box. S (−3, 0, 1), d (1, 0, 0), len 4: list [0.5, 1.0] (the exit is exactly x = 1 at t = 1); answer 0.5.
- Rotated box. Centre (10, 5, 0), A0 = (0.7071, 0.7071, 0), A1 = (−0.7071, 0.7071, 0), A2 = (0, 0, 1), half (1, 0.5, 1); S (7, 5, 0), d (1, 0, 0), len 6: the entry is the A1 face, list [0.382149, 0.617851], answer 0.382149 (x = 9.292893); the A0 slab alone would give 0.264298.
- Start inside, leave. S (0, 0, 1), len 4: list [0, 0.25]; answer 0.
- Start and end inside. S (0, 0, 1), len 0.5: no hit (t0 = 0, t1 = 1 untouched).
- Parallel. S (−3, 0.6, 1), d (1, 0, 0), len 4: the y slab is parallel with num 0.1 > 0: no hit. S (−3, 0.5, 1) (on the face plane): num 0, inside: hit, 0.5.
- Ends on the face. S (−3, 0, 1), len 2: t0 = t1 = 1.0, list [1.0]: hit at 1.0.
- Away, zero. d (−1, 0, 0): no hit. len 0 (or d = 0), outside or inside: no hit.
Capsule C: base (0, 0, 0), height 1.8, radius 0.35.
- Side. S (−2, 0, 1), d (1, 0, 0), len 4: list [0.4125, 0.5875]; answer 0.4125 (x = −0.35).
- Rounded top. S (−2, 0, 2.0), same d and len: the side roots are at z = 2.0 > 1.8, the top hemisphere gives [0.428193, 0.571807]; answer 0.428193. At z 2.2 (0.4 above the top centre, more than R): no hit. At z −0.2: the bottom hemisphere, 0.428193.
- Start inside. S (0, 0, 1), len 1: stored [−0.35, 0.35]; answer 0.35 (the exit).
- Slanted through the top, out the side. S (−1, 0, 3.2), d = (1, 0, −1.5) / 1.802776, len 4: stored [0.608437 (side exit, z 1.175), 0.343499 (top entry, z 2.056768)]; answer 0.608437, not 0.343499.
- Straight down (the quirk). S (0.1, 0, 3), d (0, 0, −1), len 4: q = 0.335410; stored [0.833853, −0.216148]; answer 0.833853 (z −0.335410, the bottom), where the true entry is 0.216148 (z 2.135410). With len 3: stored [1.111803, −0.288197]: no hit, though the ray passes 0.86 m into the capsule.
- Straight up. S (0.1, 0, −1), d (0, 0, 1), len 4: stored [0.166148, 0.783853]; answer 0.166148 (correct).
- Miss. S (−2, 0.4, 1), d (1, 0, 0), len 4: disc < 0, no hit.
RayCastBodies, ray S (0, 0, 1), d (1, 0, 0), len 10, typeMask 0xffffffff, flags of every body 0x2000 unless said:
- Nearest. Body A, box centre (5, 0, 1), half (1, 1, 1); body B, box centre (3, 0, 1), half (0.5, 0.5, 0.5): owner B, B's box, 0.25 (A's 0.4 loses).
- Filters. B without
0x2000: A, 0.4.ignored= B's owner: A, 0.4. typeMask 0: no hit. B's box disabled (flag0x1clear): A, 0.4. A sphere shape on B instead of the box: never tested, A. - A human. A human body whose capsule has base (2, 0, 0), height 1.8, radius 0.35: hit at 0.165 on the capsule; a bone shape switched on and in the ray's way changes nothing.
- Tie. Two bodies with identical boxes: the one earlier in the x-sorted list wins.
- Miss leaves
outalone. Filloutwith a pattern; a ray that meets nothing leaves every byte.
Unit 15: The physics world: construction, settle and step¶
What it is. The IPhysics object: what its constructor builds (fields, pools, the shape-pair tables, the human
body sets, the punch bag's set), the 60 Hz step (the bound refresh and the settle turns), starting and cancelling a
settle in one of 64 slots, the settle's end rotation, and the file's static initialiser.
- Coney:
src/physics/physics_world.*(one world per level, its pools and tables, the step);src/world_objects/loose_objects.*moves its settle (SettleTurn,settleAxis,settleTarget, tagged) onto the world's 64 slots;src/platform/play_level_mode.*calls the step once per 60 Hz tick after the humans and the wheel, replacing Coney's two settle ticks per 1/30 s step (LooseObjects::kTicksPerStep). - Tests: Test ideas: the pools and tables after construction, the bound refresh on the 120th step,
a settle taking 11 ticks (
t= 0.01 n (n − 1)), 64 busy slots refusing a 65th, cancelling, and the end rotations.
8 functions, 4392 bytes (3 tagged).
| Address | Name | Size |
|---|---|---|
0x0033c288 |
IPhysics_Construct |
2168 |
0x00340918 |
IPhysics_Step (tagged) |
544 |
0x00340b38 |
Settle_NearestAxis (tagged) |
464 |
0x00340d08 |
Settle_ComputeTarget (tagged) |
724 |
0x00340fe0 |
IPhysics_StartSettle |
272 |
0x003410f0 |
IPhysics_CancelSettle |
104 |
0x0034f6c0 |
Physics_StaticInit |
84 |
0x0034f718 |
Physics_StaticInitStub |
32 |
All claims confirmed (code) at the address of their section unless a line says otherwise (read in the disassembly,
2026-10-12). Owner vtable slots are written as in Unit 3 (adjust / function offsets);
the world
object's vtable is 0x005453a0 (stored by ObjectTaskPool_New, 0x004f3ef0).
IPhysics_Construct¶
0x0033c288, 0x878 bytes. IPhysics* IPhysics_Construct(IPhysics* phys /*a0*/), returns phys. Called once, by
Game_InitializeSubsystems (0x00160fa8), on a 0x4f460-byte block; the result goes to 0x00597198
(Boot). In order:
- Fields:
+0x04(human list head) = 0,+0x10(u16) = 0,+0x12(u16, ticks since the bound refresh) = 0,+0x14(the widest body) = 0.0 (0x0033c2d0-0x0033c2dc).+0x08(the body list head) = 0 later (0x0033c86c). What reads+0x10is not traced. - Pools, each element constructed in place, in this order (the loop counts are
count − 1counted down to −1):
| Offset | Count × stride | Constructor | Later use (IPhysics) |
|---|---|---|---|
+0x120 |
300 × 0x60 | sphere 0x00342aa0 |
the humans' bone spheres, 5 each |
+0x71a0 |
300 × 0x70 | segment 0x003437b0 |
the humans' bone segments, 5 each |
+0xf7b0 |
60 × 0x60 | sphere | weapon strike spheres |
+0x10e30 |
60 × 0x60 | capsule 0x003434a8 |
the humans' capsules |
+0x124b0 |
60 × 0xe0 | human body 0x00341e98 |
the human body sets |
+0x15930 |
416 × 0x80 | body 0x003418f8 |
world objects' bodies |
+0x22930 |
416 × 0x60 | sphere | world objects' spheres |
+0x2c530 |
416 × 0x90 | box 0x00342d18 |
world objects' boxes |
+0x3af30 |
255 × 0x80 | body | glass panes' bodies |
+0x42eb0 |
255 × 0x90 | box | glass panes' boxes |
+0x4be20 |
18 × 0x80 | body | cars' bodies |
+0x4c720 |
36 × 0x90 | box | cars' boxes |
+0x4db60 |
30 × 0x60 | sphere | push spheres |
What the constructors write is Unit 6 (shapes: next 0, offset (0, 0, 0, 1), bone −1,
flags 0x1) and
Unit 7 (a body: node min = max = (0, 0, 0, 1), index −1, links 0, shapes 0, owner 0,
flags
0x80000400, push-out (0, 0, 0, 1), scale from 0x005116d0; a human body adds +0x80, +0x84, +0xc0-+0xd4
= 0, a broad bound node at +0x90, and flags | 0x3f).
3. Settle slots: +0x4e6a0, 64 × 0x30; only each slot's +0x00 (the object) is set to 0 (0x0033c558).
4. The punch bag's set: a human body at +0x4f2a0, a segment at +0x4f380, a capsule at +0x4f3f0, constructed
(filled in at step 10).
5. The body vector at +0x4f450: +0x4f454, +0x4f458 and +0x4f45c = 0 (+0x4f454 is the array
IPhysics_QueryBox indexes by body +0x20; the other two inferred count and capacity; +0x4f450 is not written).
6. The tables cleared with memset (0x004307e8): +0x18 and +0x7c, 100 bytes each (5 × 5 entries);
+0xe0, +0xf4, +0x108, 20 bytes each (5 entries). Then the non-zero entries stored, exactly those of
The shape-pair tables: entry (row, column) of the overlap table at +0x18 + 0x14·row +
4·column, of the
sweep table at +0x7c + 0x14·row + 4·column, rows and columns being the shape types 1-4 (row and column 0 stay
empty); the mesh sweep, mesh overlap and ray entries at +0xe0, +0xf4, +0x108 + 4·type
(0x0033c630-0x0033c7dc; every address checked against that table, all agree).
7. Strike spheres: for each of the 60 at +0xf7b0, switch it off (shape vtable +0x18 / +0x1c with 0, which
clears 0x1) and set flag 0x2, so they end with flags 0x2 (0x0033c7e0-0x0033c820).
8. Ignore slots: +0xf4e0, 720 bytes (60 × 12) zeroed (0x0033c830).
9. The 60 human body sets (0x0033c8a8-0x0033ca44), for i = 0 to 59, body H = +0x124b0 + 0xe0·i:
- capsule K = +0x10e30 + 0x60·i: PhysicsCapsule_SetSize(K, height 2.0, radius 0.35) (0x00343508: f12 to
+0x44, f13 to +0x40); H +0x30 = K (its only shape in the +0x30 chain);
- five segments, j = 0 to 4, at +0x71a0 + 0x70·(5i + j): offset +0x10 from 0x00512810 + 0x10·j, bone +0x31
the low byte of word j of the table copied from 0x00577198, radius +0x60 from 0x005128b0 + 4j, length
+0x64 from 0x005128e0 + 4j; flags |= 0x2, and |= 0x4 when the bone is 3; appended to H's bone list
(PhysicsHumanBody_AddBoneShape 0x003429e8: H +0x80, chained through shape +0x00, appended at the tail);
- five spheres, j = 0 to 4, at +0x120 + 0x60·(5i + j): offset from 0x00512860 + 0x10·j, bone from the table
copied from 0x005771b0, radius +0x40 from 0x005128c8 + 4j; flags |= 0x2, |= 0x4 when the bone is 6;
appended the same way.
So H's bone list is, in order, the segments on bones 3, 24, 18, 32, 29 and the spheres on bones 6, 25, 19, 33, 30,
with the sizes on Combat; after construction their flags are 0x3, or 0x7 for bones
3 and 6 (enabled). IPhysics_CreateHumanBody (0x0033cc48) later switches every bone shape off and the capsule
on, which gives the 0x2 / 0x6 of the Combat table (Unit 9).
10. The punch bag (0x0033ca4c-0x0033caf4): capsule +0x4f3f0 SetSize(2.0, 0.35); bag body +0x30
(+0x4f2d0) = the capsule; bag body +0x80 (+0x4f320) = the segment directly (no append call); segment
offset +0x10 = (0, 0, 0, 1), bone +0x31 = 3, flags +0x32 = 0x4 (stored, not or-ed: not enabled, no
0x2), radius +0x60 = 0.35, length +0x64 = 1.5 (0x3fc00000). The posed sizes are in
Bodies.
Constants: 2.0 0x40000000, 0.35 0x3eb33333, 1.5 0x3fc00000. Nothing else of the object is written: the human
and body lists start empty, and the widest-body bound starts at 0 until a body is inserted.
IPhysics_Step¶
0x00340918, 0x220 bytes. void IPhysics_Step(IPhysics* phys /*a0*/). Called once per 60 Hz tick from
TaskManager_TickGame (at 0x003a2f8c), after Humans_Update and the wheel's bucket
(Tasks), with phys = [0x00597198].
- Bound refresh (
0x00340940-0x00340994).+0x12+= 1 (u16). If it is now 120 or more:+0x12= 0,+0x14= 0.0, then for each body from the head+0x08along+0x28: w = body max.x (+0x10) − min.x (+0x00); if+0x14< w,+0x14= w. So the first refresh is on the 120th step after construction, then every 120 steps, and an empty list sets the bound to 0. - Settle turns, for slot i = 0 to 63 at
+0x4e6a0 + 0x30·iwhose object O (+0x00) is not 0 (0x003409b0-0x00340b08): - t′ = t + speed; speed′ = speed + accel (both from the old values); store both; if t′ > 1.0, t′ = 1.0.
- Read O's pose: owner slot
+0xa8/+0xac(the world object'sTask_GetPose0x003a1e30: the stored position and rotation, 32 bytes). - rotation =
Quat_Slerp(t′, from = start (+0x20), to = end (+0x10))(0x00336a00, f12 = t′,a0out,a1from,a2to); keep the position. - Write the pose back: owner slot
+0x68/+0x6cwith (O, &pose). For the world object that is0x00395220(namedWorldObject_OnDetachin Ghidra): unless O is held (flag0x10) it sets the task's transform (Task_SetTransform0x003a16b8), sets byte+0x108= 1 and refreshes O's render; a held object's rotation is not changed, but its slot still advances. - If O has a body (owner slot
+0xf0/+0xf4, called three times): body+0x40 |= 0x80000000, then body slot+0x10/+0x14,PhysicsBody_Update(body, O)(0x00341c10) at once, which re-poses its shapes and re-sorts it. - If t′ = 1.0 exactly: O's flags (
+0x54)&= ~0x40000,|= 0x8000000; the slot's object = 0.
With accel 0.02 (0x3ca3d70a, 0.0199999996) and t = speed = 0 at the start, t after tick n is 0.01·n(n − 1); in
float32: 0, 0.02, 0.06, 0.12, 0.2, 0.3, 0.42, 0.56, 0.72, 0.9 (0.89999986), then 1.1 clamped to 1.0 at tick 11. The
first tick changes nothing visible (t stays 0).
IPhysics_StartSettle¶
0x00340fe0, 0x110 bytes. void IPhysics_StartSettle(IPhysics* phys /*a0*/, WorldObject* O /*a1*/, const vec4*
normal /*a2*/, u32 axisMask /*a3*/). One caller, WorldObject_OnContact (0x00394714): normal = the contact's
+0x50, axisMask = the type's axis byte (+0x85), only when that byte is non-zero and O's flags have neither
0x40000 nor 0x8000000 (Settling).
- Walk the 64 slots in index order; take the first slot that is free or already holds O
(
0x00341038-0x003410c0). A free slot before O's own is taken first, so a second call for an object that is already settling would give it two slots; the one caller never does that. - No slot: return, with nothing changed: no flag, the angular velocity kept, no end rotation (the 65th settle is refused silently).
- The slot:
+0x08accel = 0.02,+0x00= O,+0x04t = 0,+0x0cspeed = 0. - start (
+0x20) = the rotation of O's pose from owner slot+0xa0/+0xa4: for the world objectObj_GetPoseNow(0x003a1ad8), the pose extrapolated to now. Its rotation is O's rotation (+0x20) turned by the angular velocity (+0x40) over dt = phase time − O's update stamp (+0x50), at least 0 (0x003a1688), when that velocity is non-zero and O is neither held (0x10) nor flagged0x1000(which slerps).WorldObject_Integrate(0x00395a10) sweeps (0x00395b14, where the contact calls this) before it stamps+0x50(0x00395b48), so dt here is the whole interval since O's last update (1/30 s in flight): the settle starts from the rotation O would have reached at the end of this update. Inferred that the extrapolation matches the integrator's own turn (Quat_IntegrateAngular0x00335b08), which this update then skips because O is settling. - O's angular velocity = (0, 0, 0, 1) (
0x005116c0) through owner slot+0x78/+0x7c: for the world object0x003a19a0(no function in Ghidra:lq/jr/sqstoring the vector at O+0x40). - O's flags
|= 0x40000. Settle_ComputeTarget(phys, slot, normal, axisMask)writes the end rotation.
IPhysics_CancelSettle¶
0x003410f0, 0x68 bytes. void IPhysics_CancelSettle(IPhysics* phys /*a0*/, WorldObject* O /*a1*/). One caller,
WorldObject_Remove (0x00391c38). First O's flags &= ~0x40000, always, slot or not; then the first slot (in
index order) whose object is O gets object 0. Nothing else: O keeps the rotation it had reached, 0x8000000 is not
touched, and a second slot for O (step 1 above) would survive.
Settle_NearestAxis¶
0x00340b38, 0x1d0 bytes. void Settle_NearestAxis(IPhysics* /*a0, unused*/, vec4* out /*a1*/, u32 mask /*a2*/,
const quat* q /*a3*/, const vec4* n /*t0*/).
- M = the rotation matrix of q (
0x00336500); its rows 0, 1, 2 are the object's local x, y, z axes in the world. - best = 1e9, k = −1. For i = 0, 1, 2 with mask bit i set: c = row_i · n; the angle is 0 if c ≥ 1.0, π
(
0x40490fdb) if c ≤ −1.0, else π/2 − atan(c / √(1 − c²)) (0x003356a0; π/2 is0x3fc90fdb), which is acos(c). If angle < best (strict), best = angle, k = i. So the positive axis nearest n wins, ties to the lower index. - Bit 3 of the mask (
_ROUND): for each axis whose bit is clear, a local records the first such index, then 3; it is never read (Settling). out= row k. With no allowed axis (mask & 7 = 0) k stays −1 andoutis read from 16 bytes below the matrix (stack garbage). The one caller passes a non-zeroaxisbyte, and no type inlevel99hasaxis8 (bit 3 alone), so this is not reached in play; Coney'ssettleAxisreturns none instead.
Comparing the dot products (largest first, strictly) as Coney does gives the same axis except when two angles round to the same float.
Settle_ComputeTarget¶
0x00340d08, 0x2d4 bytes. void Settle_ComputeTarget(IPhysics* phys /*a0*/, SettleSlot* s /*a1*/, const vec4* n
/*a2*/, u32 mask /*a3*/). Reads the slot's start (+0x20), writes its end (+0x10).
- a =
Settle_NearestAxis(phys, mask, start, n). - c = a · n; k = n × a (
vopmula/vopmsub), then k̂ = k × rsqrt(k·k) (vrsqrt). - θ = 0 if c ≥ 1.0, π if c ≤ −1.0, else acos(c) as above.
- If k̂·k̂ < 1e-6 (
0x358637bd): θ = 0 and k̂ = a. The test is on the normalised k̂: when a and n are parallel (k = 0) the EE's rsqrt of 0 gives a huge value and 0 × huge is 0, so k̂ = 0 and the test fires. Under IEEE that product is NaN and the test fails: an implementation must test |k| itself. - Wrap: if θ > π/2, θ −= π. (A second test, θ < −π/2 → θ = −(θ + π), cannot fire after the first.) Then θ′ = −θ.
- r = (k̂·sin(θ′/2), cos(θ′/2)) (
sinf0x004b8c40,cosf0x004b8a70; x, y, z then w at+0x0c). - end = r ⊗ start, the Hamilton product (w = r_w·s_w − r·s, xyz = r_w·s + s_w·r + r × s;
0x00340f28-0x00340f58). - If end · start (4D) < 0, end = −end. Since (r ⊗ s)·s = r_w for a unit s and |θ′| ≤ π/2 gives r_w ≥ 0.7071, this never fires for a unit start (inferred, from the algebra).
The turn by θ′ = −acos(a·n) about n × a carries a onto n; when a is more than 90° from n it is turned by π − θ the
other way, which carries a onto −n. Either way the face across a lies on the surface: the object rests on its nearest
allowed face. Coney's settleTarget (src/world_objects/loose_objects.cpp) computes the same rotation by flipping a
and taking the shortest arc.
Physics_StaticInit and its stub¶
0x0034f6c0, 0x54 bytes: void Physics_StaticInit(int initialise /*a0*/, int priority /*a1*/), GCC's static
initialiser for physics.cpp. When priority = 0xffff and initialise ≠ 0 it copies three vec4 from 0x00511720,
0x00511730, 0x00511740 to 0x006eb9e0-0x006eba0f: (1, 0, 0, 1), (0, 1, 0, 1), (0, 0, 1, 1), the three unit
axes with w = 1. The stub 0x0034f718 (from the constructor list at 0x00534198) calls it with (1, 0xffff). No
reader of 0x006eb9e0 was found (Ghidra lists only this write; an operand search for its low half -0x4620 finds no
other use with that base), so it is inferred to be a file-scope constant nothing reads. Coney needs nothing for it.
Test ideas¶
Synthetic: a world built by Coney's constructor, fake owners with a pose, a flags word and an optional body.
- Pools. After construction: 300 bone spheres and 300 bone segments, 60 each of strike spheres, capsules and
human body sets, 416 / 255 / 18 bodies and 416 spheres, 416 / 255 / 36 boxes, 30 push spheres, 64 free settle
slots, 60 free ignore slots; lists empty,
+0x12= 0,+0x14= 0. - Human set i. Capsule radius 0.35, height 2.0, flags
0x1, the body's only+0x30shape; bone list order bones 3, 24, 18, 32, 29, 6, 25, 19, 33, 30, segment lengths 0.38, 0.20, 0.20, 0.40, 0.40, radii 0.18, 0.07, 0.07, 0.10, 0.10, sphere radii 0.15, 0.09, 0.09, 0.15, 0.15; flags0x7for bones 3 and 6,0x3for the rest. Strike spheres0x2. The bag's segment: bone 3, flags0x4, radius 0.35, length 1.5. - Tables. Every entry of The shape-pair tables is where the offsets say; row 0, column 0 and the listed "none" entries are empty; the ray table has types 1 and 3 only.
- Bound refresh. Bodies of x widths 1.0 and 3.0: a body of width 5.0 inserted then removed leaves
+0x14= 5.0 for steps 1-119 (the insert grows it, Unit 7); step 120 sets it to 3.0 and+0x12to 0; with no bodies step 120 sets 0.0. - Settle timing. A slot started at tick 0: t after each step 0, 0.02, 0.06, 0.12, 0.2, 0.3, 0.42, 0.56, 0.72,
0.9, 1.0 (float32 within 1e-6); at step 11 the flags go
0x40000→0x8000000, the slot is free and the rotation equals the end. - Refusal. 64 objects settling: a 65th
StartSettleleaves its flags, angular velocity and rotation untouched and no slot changes. - Slot order. Slots 0-63 busy, then slot 5 freed (cancel): the next start takes slot 5.
- Cancel. Clears
0x40000on an object with no slot; on a settling object frees its slot after, say, 4 steps and leaves the rotation at t = 0.12 of the turn;0x8000000untouched. - Held. A settling object with flag
0x10: its rotation does not change, its slot still finishes at step 11. - Body follows. An object with a body: after each step the body is marked dirty and re-posed (its AABB follows the turned box).
- End rotation (quaternions as (x, y, z, w), normal (0, 0, 1)): start identity, mask 4 (z): end = identity. Start identity, mask 1 (x): end = (0, −0.707107, 0, 0.707107) (x turned onto +z). Start 30° about y (0, 0.258819, 0, 0.965926), mask 4: end = identity. Start 120° about y (0, 0.866025, 0, 0.5), mask 4: θ = 120° wraps to −60°, end = (0, 1, 0, 0) (local z points down: the face across z is on the floor).
- Parallel guard. Start identity, mask 4, normal (0, 0, −1): c = −1, θ = π wraps to 0, end = start (no NaN).
- Start pose. An object spinning (angular velocity non-zero) that lands: the settle's start is its rotation advanced by the angular velocity over the time since its last update, and its angular velocity is then zero.
Unit 16: RayCast leftovers and the world manager's ray¶
What it is. The material id to name lookup, the collision mesh's chunk frees, the empty per-frame mesh update and
the world manager's ray cast, the one through which everything outside RayCast/ and the cameras casts into the
level. All claims below are confirmed (code) at the cited addresses (read in the disassembly, 2026-10-12) unless a
line says otherwise; no runtime claims.
- Coney:
src/raycast/collision_mesh.*: a material name table (reference data, names only) and a world ray whose result keeps the original's preset and its pass-through fill (WorldManager_RayCast); the callers keep mask 0 and no exclusion list, which is what the original passes almost everywhere (Who passes what). The mesh's destruction order is Unit 17's. - Tests: Test ideas: the names of ids 0, 1, 2, 116, 190, 191 and 255; the world ray's preset (material 1, hit 0, every other field left as the caller had it) and its fill by an excluded triangle.
4 functions, 2636 bytes (0 tagged).
| Address | Name | Size |
|---|---|---|
0x0034fc08 |
CollisionMaterial_GetName |
2340 |
0x00350688 |
CollisionMesh_FreeChunks |
240 |
0x00351158 |
CollisionMesh_UpdateEmpty |
8 |
0x0040db88 |
WorldManager_RayCast |
48 |
CollisionMaterial_GetName¶
0x0034fc08, const char* CollisionMaterial_GetName(int id) (a0 id, v0 the name). No static caller (no
cross-reference reaches it; inferred: a debug or tool leftover, or a caller through a pointer not found).
- Bound:
id < 191, compared unsigned (sltiu a0, 0xbfat0x0034fc08). Any other value, including every negativeint, returns the stringunknown(0x005785c8, the default case at0x00350520). - Table: otherwise jump through the 191-word table at
0x005785d0. Every entry points at its own 12-byte case (0x0034fc2c + 12 × id), and every case returns a different string: no two ids share a name, no id falls to the default. The strings run from0x00577560(id 0) to0x005785b8(id 190) in id order.
Note the two "no material" names: id 0 is undefined and an id past 190 is unknown; id 1 is MATERIAL_NONE.
The material names¶
A curated reference list (names only, read from the strings the cases return). Every name is MATERIAL_ followed by
the word given, except id 0 (undefined, no prefix) and id 1 (written in full). ASHPHALT, LAMPOST,
GUILOTINE, PARKNMETER and SWITCHBLD are spelt as the game spells them.
0 undefined, 1 MATERIAL_NONE, 2 GLASS, 3 STEEL, 4 ALUMINIUM, 5 CONCRETE, 6 ASHPHALT, 7 LAMPOST,
8 SHOE, 9 FIST, 10 TORSO, 11 GARBAGE_CAN, 12 DUMPSTER, 13 CAR_HOOD, 14 CAR_HOOD_STREAM, 15 DRYWALL,
16 WOOD, 17 HEAD, 18 TRAINRAIL, 19 SHOVEL, 20 TRAINCAR, 21 METALDIVIDER, 22 GARBAGEBAG, 23 TINBOX,
24 RUBBLE, 25 ROLLUPDOOR, 26 HUMAN, 27 TV, 28 DRUM, 29 BAT, 30 LOW_FENCE, 31 OPAQUE_FENCE, 32 MAILBOX,
33 ELBOW, 34 KNEE, 35 GRASS, 36 DIRT, 37 BOTTLE, 38 FRIDGE, 39 SIGNS, 40 BASKETS, 41 SKID, 42 GEAR,
43 TRASHCAN, 44 TRASHCANLID, 45 SOFAS, 46 TYPEWRITER, 47 WOODFURNITURE, 48 MILKCRATE, 49 RADIATOR,
50 METALCHAIR, 51 OFFICECHAIR, 52 CINDERBLOCK, 53 WHEELCHAIR, 54 STEELBARRICADE, 55 THINWOOD, 56 MEDWOOD,
57 SHOPPINGCART, 58 HEAVYBOXES, 59 HOTDOGCART, 60 POOLBALL, 61 EMPTYBARREL, 62 FULLBARREL, 63 GARDENTOOLS,
64 PAN, 65 CROWBAR, 66 STICKS, 67 BASEBALL, 68 FIREEXTINGUISHER, 69 BENCH, 70 WELDINGTANKS, 71 TIRE,
72 RAT, 73 FIREWHOOSH, 74 STEELOBJECTS, 75 SOLIDOBJECTS, 76 ELECDEBRIS, 77 METALRODS, 78 GUILOTINE,
79 MINECART, 80 RIDECART, 81 PICKAXE, 82 WOODTABLE, 83 WIDEBOARD, 84 SMALLBOARD, 85 DEBRIS, 86 CHAIR,
87 LAMP, 88 GLASS_SMALL, 89 MUG, 90 MACHETE, 91 WOODFENCE, 92 MOLOTOV, 93 PORCELAIN, 94 PORCE_PART,
95 CAMERA_GHOST, 96 BLASTER, 97 SWITCHBLD, 98 PAINT, 99 CERAMIC, 100 CERAM_PART, 101 FEET, 102 BRICK,
103 CASHREG, 104 GLASS_FRAGILE, 105 ROOFTOP, 106 GRAVEL, 107 CHAINLINK_NOCLIMB, 108 BAG, 109 MEAT,
110 PLASTIC, 111 JAB, 112 BIGPUNCH, 113 BIGKICK, 114 VEGETATION, 115 GARBAGE, 116 BRICKWALL,
117 WOODWALL, 118 STEELWALL, 119 CAR_PART, 120 CAR_PART_STREAM, 121 CARPET, 122 RAILING, 123 JUKEBOX,
124 BEAM, 125 CABINET, 126 BATON, 127 BLOCK, 128 WEAPON_BLOCK, 129 CAR_DAMAGE, 130 RAZOR_CLUB,
131 DRUMKIT, 132 CYMBAL, 133 WOODSPLINTER, 134 GONG, 135 KEYBOARD, 136 PAINTCAN, 137 CHICKENBOX,
138 RICEBAG, 139 SPIKEBAT, 140 GLASSPART, 141 PLYWOOD, 142 GARBPART, 143 RUBBLEDEBRIS, 144 METALDEBRIS,
145 PAPERDEBRIS, 146 BIGWOOD, 147 WIREFRAME, 148 SMALLBOARDPART, 149 WIDEBOARDPART, 150 TINYWOOD,
151 SPEAKER, 152 CHAINMETAL, 153 CHAINWOOD, 154 GUITAR, 155 DRUMSTICK, 156 PARKBENCH, 157 PLASTERWALL,
158 BROKENBOTTLE, 159 TORSO_PRONE, 160 BREAKABLEDOOR, 161 HUMAN_LIGHT, 162 HUMAN_HEAVY, 163 FEET_LIGHT,
164 FEET_LAND, 165 COKEMACHINE, 166 PHONE, 167 FIREHYDRANT, 168 PARKNMETER, 169 PAINTING, 170 SUBWAYSEAT,
171 AQUARIUM, 172 DEFAULT_OBJ, 173 LIZZIE_DOOR, 174 BOSS_SHOE, 175 BOTTLE_SPECIAL, 176 MOLOTOV_SPECIAL,
177 BRICK_SPECIAL, 178 CHAIR_SPECIAL, 179 CLIMB_WOOD, 180 CHIMNEY, 181 STATUE, 182 STATUE_PIECE,
183 BOSS_FIST, 184 DEAD, 185 ICECREAM, 186 SAND, 187 STOREDOOR_GLASS, 188 PUNCHING_BAG, 189 FOAMHAND,
190 SALAMI.
The same 191 ids index the sound matrix's material side (src/audio/sound_matrix.h, kMaterials = 191, from
enum_preload.lua); that the two lists name the same ids in the same order is inferred from the count, not compared.
CollisionMesh_FreeChunks¶
0x00350688, void CollisionMesh_FreeChunks(CollisionMesh* mesh); the anchor of RayCast/CollisionMesh.cpp (path
string 0x005788f8). Its only caller is the mesh's deleting destructor CollisionMesh_DeletingDtor (0x004f12a8,
vtable 0x005448b0 slot +0x08), which writes the mesh's vtable, calls it, writes the base vtable 0x00534298 and
calls operator delete only when its flags have bit 0.
It frees five pointers through the memory manager's Free (0x005127e4, slot +0x58, Memory): each
goes to the pool on top of the heap stack, with the file string and a line number. Order:
| # | Field | Chunk | Line |
|---|---|---|---|
| 1 | +0x80 |
0x06 index lists |
285 |
| 2 | +0x78 |
0x05 grid |
286 |
| 3 | +0x88 |
0x04 triangles |
287 |
| 4 | +0x8c |
0x07 vertices |
288 |
| 5 | +0x94 |
0x52 checked bits |
289 |
No pointer is tested for null and none is cleared afterwards. The header (the 0x03 chunk, which is the mesh
object) is not freed here: the caller's delete frees it after the destructor (LevelObject_Destroy).
Nothing else in the mesh is touched.
CollisionMesh_UpdateEmpty¶
0x00351158: jr ra and nothing else. Called with no arguments by eleven game modes' Update functions, not only
mode 1's: Gm_Level_Update (0x00158d0c), PauseMode_Update, Gm_ANGameOver_Update, Gm_MissionFailed_Update,
GameStats_Update, RumbleResult_Update, MissionSelect_Update, the modes 0x0e, 0x11 and 0x12, and
Gm_Error_DrawOverWorld (call sites 0x00155d00-0x0015f380). Coney needs nothing for it; a tag on the mode update
is enough.
WorldManager_RayCast¶
0x0040db88, void WorldManager_RayCast(WorldManager* wm, const Ray* ray, RayResult* result, const u32*
excludeList, u32 mask): a0..a3 and t0 in the EE ABI. The ray and result records are on
Collision.
result+0x28 = 1(au32store:MATERIAL_NONE) andresult+0x10 = 0(hit). Nothing else is preset: the normal (+0x00),t(+0x14),+0x18, the flags (+0x2c), the area byte (+0x2e) and the triangle pointer (+0x30) keep whatever the caller left in them.- Call
CollisionMesh_RayCast(*(wm+0x40) + 0x04 → mesh, ray, result, excludeList, mask)with every other argument passed through unchanged.wm+0x40(the level object) is read without a null check: a cast beforeLoadLevelor afterWorldManager_Unload(0)reads a null or a freed level (Unit 18). - Return value: none worth reading.
CollisionMesh_RayCast(0x00350cd8) returns withv0holding the last loop comparison of its cell walk (sltuat0x00351110), and the wrapper passes it on. Callers read+0x10.
What the cast then writes, restated from the cell test (0x00350778) so this unit is complete:
- For each triangle reached and accepted (
0 <= t <= length), the material filter (0x00350538) answers 1 (kept) or 0 (inexcludeList). - While
hitis 0, the triangle fills the result whatever the filter said and with no nearer-than test:t,hit= the filter's answer,+0x18= 0, the material (u32at+0x28), the flags& 0xfff(u16at+0x2c), the area byte (+0x2e) and the triangle (+0x30). Only a kept triangle (filter 1) also writes the unit normal (+0x00), flipped to face the ray when the triangle is two-sided andn·dir > 0. - Once
hitis 1, an excluded triangle is skipped, and a kept one replaces the result only when itstis smaller than the storedt.
So after a cast: hit 1 means the nearest kept triangle is in the result. hit 0 with material 1 means nothing was
crossed (and t, normal, flags, area and triangle are the caller's old values). hit 0 with any other material
means an excluded triangle was crossed and none kept: the result holds the last excluded triangle tested (in the
walk's cell order, then the cell list's order), its t, flags, area and triangle, and the caller's old normal. A
kept triangle met after an excluded fill becomes the hit whatever the two t values.
Who passes what¶
WorldManager_RayCast has 61 call sites in 46 functions (the "46 callers" of Collision counts functions). Read at
each call site, 2026-10-12:
- No exclusion list and mask 0 at 55 sites, among them
Collision_DropToGround(0x0034f994),Collision_DropToMarkedGround(0x0034faa0),Collision_MarchRay(0x0034f888), every climb probe (Climb_TryStart,Climb_ProbeTop,Climb_Start, the twoClimb_OnFirstClipEnd*), the human's ground and landing rays (Human_SnapToGround0x0023eb9c,Human_LandingTest0x0023e5a4,Human_StartFall,Human_SetTransform,Human_Init,Human_TeleportNear),Player_TryJump,Player_Throw, the AI's line and point tests andExplosion_DamageHumansInRadius(0x00392834). So Coney's mask 0 and empty list for the ground helpers and the human are the original's, not a choice. - Mask 8, no list: the first cast of
Human_CanSeeObject(0x0021c69c); its second (0x0021c780) passes 0. - A list and a mask:
Human_TestStrikes(0x0033f6c0) passes the list at itssp+0x930and mask0x48, or another value chosen by a test just before (movnat0x0033f6a8; not read further here). - Not read:
Ray_IsClear(0x0024e040), the second cast ofGarbage_Update(0x001710bc) andInteract_FindPartner(0x002a9454) seta3andt0outside the 25 instructions before the call.
What Coney changes¶
- Material names. A constexpr table of the 191 names above (
collision_mesh.hor amaterials.hbeside it) and amaterialName(int id)that returnsunknownfor any id outside 0 to 190, negative ones included.@orig 0x0034fc08 CollisionMaterial_GetName (unknown); the file is inferred to be inRayCast/, not read. - The world ray.
CollisionMesh::rayCastreturnsstd::optional<RayHit>and skips an excluded triangle before intersecting (collision_mesh.cpp, the exclusion test in the cell loop), so it cannot report the see-through triangle a clear ray crossed, which AI: GetItem reads as "an intact pane in the way". Add a world-ray result that mirrors the original's:hit,t, normal, material (preset 1), flags, area and triangle, filled as above, with aworldRayCast(level, ray, result&, exclude, mask)that sets onlymaterial = 1andhit = falsebefore the walk and leaves the rest of the caller's result alone.@orig 0x0040db88 WorldManager_RayCast (WorldManagerPS2.cpp)(inferred file: it sits in theWorld/ps2/WorldManagerPS2.cpprange, Source map).rayCastcan stay as the convenience form that returns only a kept hit. - No level. The original dereferences a missing level; Coney returns no hit (material 1) and asserts in debug builds, a marked Coney choice.
- Freeing the mesh. Nothing to add: the mesh owns its chunks, and the order of the five frees is not observable (Unit 17 gives the level object's order, which is).
Test ideas¶
All on synthetic meshes from src/raycast/collision_builder.h; expected values follow from the spec above.
- Names:
materialName(0)=undefined,1=MATERIAL_NONE,2=MATERIAL_GLASS,116=MATERIAL_BRICKWALL,190=MATERIAL_SALAMI,191=unknown,255=unknown,-1=unknown; 191 distinct names. - Preset, nothing crossed: an empty mesh area, ray from (0, 0, 1) along +x, length 5, a result prefilled with
t= 7.0, normal (0, 0, 9) and triangle 42: after the casthitfalse, material 1,t7.0, normal (0, 0, 9), triangle 42. - A pane crossed, glass excluded: a two-sided vertical triangle of material 2 across x = 1 and a ray from
(0, 0, 1) along +x, length 5, list {2}:
hitfalse, material 2,t= 1.0, the pane's triangle, normal still the caller's. - The same pane, no list:
hittrue, material 2,t= 1.0, normal (−1, 0, 0) (flipped to face the ray). - Excluded then kept, farther: the pane at x = 1 (material 2) and a wall at x = 3 (material 3), list {2}:
hittrue,t= 3.0, material 3. - Kept then excluded, nearer, in the same cell (wall listed before the pane):
hittrue,t= 3.0, material 3 (an excluded triangle never replaces a kept hit). - Two excluded panes in one cell's list, at x = 2 listed first and x = 1 listed second, list {2}:
hitfalse,t= 1.0 (the last tested, not the nearest); listed the other way round,t= 2.0. - Mask: a floor with flag
0x08under a ray cast with mask 8 is not hit (material stays 1); with mask 0 it is.
Unit 17: Level file and level object lifecycle¶
What it is. The WAD object's opener and lookups, the level file's load (WorldLevel_Load), the 0x17 handler's
registration, the level object's constructor, destructor and occluder setter, and WorldLevel_Release, which
destroys the level object on unload. Confirmed (code) at the cited addresses unless a line says otherwise.
- Coney:
src/fileio/wad.*(the lookups),src/world/level_object.*(an explicit destructor in the original's order, theWorldLevel_Loadtag),src/platform/level_file.*, and the unload path that stands forWorldLevel_Release(What Coney changes). - Tests: Test ideas: a synthetic WAD index finds, sizes and misses entries in any case; a level file round trip leaves both chunk stacks empty; destroying the level object releases its parts in the original's order (models before their dictionaries).
10 functions, 1028 bytes (0 tagged).
| Address | Name | Size |
|---|---|---|
0x0040c5e0 |
Wad_Open |
68 |
0x0040c628 |
Wad_FindEntry |
28 |
0x0040c648 |
Wad_GetEntrySize |
32 |
0x0040c668 |
Wad_HasEntry |
32 |
0x0040c688 |
WorldLevel_Load |
356 |
0x0040c7f0 |
WorldLevel_Release |
116 |
0x0040cf18 |
LevelHeader_RegisterChunkHandler |
40 |
0x0040cf40 |
LevelObject_Construct |
60 |
0x0040cf80 |
LevelObject_Destroy |
264 |
0x0040d088 |
LevelObject_SetOccluders |
32 |
The WAD object¶
A one-word object (+0x00 the DVDWadIndex*), the global 0x006f39e8 reached through 0x005147a4
(Boot).
Wad_Open(wad)(0x0040c5e0, boot step 8):wad+0x00 = DVDWadIndex_Create("cdrom0:\WARRIORS.DIR;1")(0x00149040), thenMS_OpenStreamFile(0, "cdrom0:\WARRIORS.WAD;1", 0)(0x001500a8, the IOP command0x1f). No error check of either (File I/O).Wad_FindEntry(wad, name)(0x0040c628): returnsDVDWadIndex::Find(wad+0x00, name)(0x001490b8): the 12-byte entry or null.Findlowercasesnamein place before hashing (Resolving a name), so the caller's buffer changes. Callers:IopStream_Start(0x00149410) andAudioDevice_LoadBank(0x0014c620).Wad_GetEntrySize(wad, name)(0x0040c648):Find(...)then the entry's+0x04(size), with no null check: a missing name reads address 4. Caller:PS2StreamFileSys_GetSize(0x00148ba8).Wad_HasEntry(wad, name)(0x0040c668):Find(...) != 0. Caller:PS2StreamFileSys_Exists(0x00148bf0).
The names these receive are already prefixed ./ee_files/ by the stream file system (File
I/O); the WAD object adds nothing.
WorldLevel_Load¶
0x0040c688, LoadedObject* WorldLevel_Load(const char* name). Its only caller is WorldManager_LoadLevel, which
has the World Level Pool on top of the heap stack. Steps:
snprintf(buf, 255, "%s.lev", name)(format0x00588638, a 256-byte stack buffer).- Push the
Level Dynamic & LUA Pool(0x006eb9b8).file = fs.Open(buf, 0)(device0x0050b788+0x90, the current file system, slot+0x60). Build aBufferedStreamon the stack overfile, the file manager's buffer (+0x3c) and its size (+0x40). Pop. ChunkSystem_LoadContainer(stream)(0x00144180) with the caller's pool on top: every chunk and object the level file makes goes into theWorld Level Pool.- Push the Lua pool; detach the stream;
fs.Close(file)(slot+0x68); pop. level = ChunkSystem_PopObject(); destroy the stream (File_Destroy(stream, 2)); returnlevel.
No step checks a failure: a missing file, a short read or an empty object stack is not detected here (inferred: the file system and chunk system handle or ignore them; not traced). Nothing checks that the popped object is a level.
LevelHeader_RegisterChunkHandler¶
0x0040cf18: ChunkSystem_SetHandlers(0x17, 0x0040ce30, 0) (0x00144348): chunk 0x17 gets onLoaded =
LevelHeader_OnLoaded and no second handler. Boot step 9 (Chunk
system).
LevelObject_Construct and LevelObject_SetOccluders¶
LevelObject_Construct(level)(0x0040cf40):+0x00= vtable0x00545b88; the ten words+0x04to+0x28= 0; returnslevel. It runs on the0x17chunk's own 48 bytes, so+0x2cto+0x2fkeep the chunk's bytes (unused).LevelHeader_OnLoaded(0x0040ce30) calls it only whenPopChunk(0x17)is non-null, and then writes through the result unconditionally (a missing chunk would write through null).LevelObject_SetOccluders(level, occluders)(0x0040d088):level+0x28 = occluders, thenOccluders_Set(occluders)(0x0017a560), which converts their points (Occluders).
The vtable 0x00545b88 has one used slot: +0x08 / +0x0c = LevelObject_Destroy.
LevelObject_Destroy¶
0x0040cf80, void LevelObject_Destroy(LevelObject* level, u32 flags). Runs from WorldLevel_Release on every
WorldManager_Unload(keep = 0) (below); this answers Level loading's
"no caller on the unload path". Order of frees, exactly:
| # | What | How | Then |
|---|---|---|---|
| 1 | vtable | +0x00 = 0x00545b88 |
|
| 2 | collision mesh +0x04 |
SafeDelete 0x004f4b78: if non-null, its destructor (slot +0x08, flags 2: FreeChunks), then Free(mesh) (WorldLevelPS2.cpp, line 144) |
+0x04 = 0 |
| 3 | occluders +0x28 |
SafeFree 0x004f4bf8: if non-null, Free (line 147) |
+0x28 = 0 |
| 4 | level world +0x0c |
device (0x0050cdb4) slot +0x178: destroy the RenderWare world |
+0x0c = 0 |
| 5 | glow dictionary +0x08 |
device slot +0x158: destroy the texture dictionary |
+0x0c = 0 again; +0x08 is never cleared |
| 6 | skyline model +0x14, then its dictionary +0x10 |
0x004693f0 (clump destroy), 0x00488c58 (dictionary destroy) |
not cleared |
| 7 | sky box +0x1c, then +0x18 |
the same | not cleared |
| 8 | cloud box +0x24, then +0x20 |
the same | not cleared |
| 9 | vtable | +0x00 = 0x00534298 (the base) |
|
| 10 | the object | operator delete (0x0042c820) only when flags & 1 |
Steps 4 to 8 pass their pointers without a null test (the device's and RenderWare's functions are not read here for
their null handling). Every Free goes to the pool on top of the heap stack, which WorldManager_Unload has made
the World Level Pool. Each model goes before its own dictionary, and the three model pairs go in the order
skyline, sky box, cloud box, which is not the order the handler linked them (sky box, skyline, cloud box).
WorldLevel_Release¶
0x0040c7f0, void WorldLevel_Release(LevelObject* level); anchor of World/WorldLevel.cpp (path 0x00588640).
Only caller: WorldManager_Unload with keep = 0, passing wm+0x40. Steps:
LightGlow_Release(lightManager)(0x0017e680on0x0050cce4, Lighting).Captions_Release(0x00619570)(0x001cafa0, Movies).- Delete the level object:
SafeDelete_WorldLevel(level, "WorldLevel.cpp", 45)(0x004f4af8): if non-null, the destructor through vtable+0x08with flags 2 (above), thenFree(level)into the current pool. - Five calls to empty functions (
0x00386f58,0x00386f68,0x00386f78,0x00386f88,0x00386f98, eachjr ra).
There are no string tables here: what Level loading calls "frees the string tables" is step 3 and the empty calls.
What Coney changes¶
- WAD lookups.
io::Wad::lookupalready coversWad_FindEntryandWad_HasEntry(any case, no in-place lowercasing, aNotFounderror for a miss). AddentrySize(name)returningstd::expected<std::uint32_t, Error>forWad_GetEntrySize, where the original crashes on a miss (a marked Coney choice). Tags:@orig 0x0040c5e0 Wad_Open (World/unknown)onWad::open,0x0040c628 Wad_FindEntry,0x0040c648 Wad_GetEntrySize,0x0040c668 Wad_HasEntry(allWorld/unknown). - The level file.
world::loadLevelFilestands forWorldLevel_Load; its comment says the function is in the tolua range and carries no tag, which Level loading has since answered (game code inWorld/, no path string). Tag it@orig 0x0040c688 WorldLevel_Load (World/unknown). Its stack checks are Coney's (the original checks nothing). - The destructor.
world::LevelObject's members are destroyed in reverse declaration order: subtitles, path data, occluders, cloud box, sky box, skyline, level world, glow dictionary, collision. Give it an explicit destructor that releases them in the original's order (table): collision, occluders, level world, glow dictionary, then each model before its dictionary, skyline, sky box, cloud box. WithinLevelModelthe default order already destroys the model (declared second) before the dictionary.@orig 0x0040cf80 LevelObject_Destroy (WorldLevelPS2.cpp). Whether the order shows to a player is not known (inferred: it matters only for texture reference counts, which librw keeps itself); it costs nothing to keep. - Release on unload.
WorldLevel_Release's three effects belong to Coney's unload (Unit 18): the light glows reset, the captions cleared, the level object destroyed.@orig 0x0040c7f0 WorldLevel_Release (WorldLevel.cpp). - Small ones.
LevelObject_ConstructandLevelHeader_RegisterChunkHandlerare whatmakeLevelHeaderHandlerandaddLevelFileHandlersalready do; tag them there.LevelObject_SetOccludersis the occluders' assignment in the0x17handler (readOccludersdoes the conversion).
Test ideas¶
- Lookups on a synthetic index of three entries named
./ee_files/level1.lev,./ee_files/warriors.glrand./ee_files/a.pakwith sizes 100, 200, 300:lookup("LEVEL1.LEV")finds the first,entrySize("warriors.glr")= 200,lookup("missing.lev")andentrySize("missing.lev")fail withNotFound, a name differing only in case hashes the same. - A level file round trip (synthetic chunks in the 18-chunk order of The level
file): after
loadLevelFileboth chunk stacks are empty and the object's ten fields are set. - Destruction order: with recording stand-ins for the platform objects, destroying a
LevelObjectrecords collision, occluders, level world, glow dictionary, skyline model, skyline dictionary, sky model, sky dictionary, cloud model, cloud dictionary. - No leak: a level object built over a synthetic collision mesh and destroyed leaves nothing allocated (the
asanpreset, or an allocation counter in the test).
Unit 18: World manager lifecycle¶
What it is. The Sector Pool's creation at boot, the world manager's constructor (the global data), loading and
unloading a level, and its small accessors. This unit settles the pool sizes, the heap-stack pairing, what an unload
gives back and what it leaves behind. Confirmed (code) at the cited addresses unless a line says otherwise.
- Coney: a platform-neutral
world::WorldManager(src/world/world_manager.*, new) owning the level's budget reservations, its streamed worlds' state and its level object, withloadLevel(name, headerOnly)andunload(keepLevelFile);src/platform/world_viewer_mode.cpp(loadLevelScenery),play_scenery.*,game_session.*andplay_level_world.cppgo through it (What Coney changes). - Tests: Test ideas: the pool sizes; load then
unload(0)returns the budget to what it was;unload(1)keeps the level file's reservation;ResetVisibilityclears the latch and both worlds' marks; the load priority stores its value as given (no clamp).
10 functions, 3700 bytes (0 tagged).
| Address | Name | Size |
|---|---|---|
0x0040d688 |
WorldManager_CreatePools |
628 |
0x0040d900 |
WorldManager_Construct |
644 |
0x0040dbb8 |
WorldManager_LoadLevel |
1280 |
0x0040f5b8 |
WorldManager_Unload |
664 |
0x0040e0b8 |
WorldManager_ResetVisibility |
68 |
0x0040e1a0 |
WorldManager_QueuePack |
308 |
0x0040e8d0 |
WorldManager_GetSolidWorld |
8 |
0x0040f850 |
WorldManager_GetSectorPool |
80 |
0x0040c938 |
WorldManager_SetLoadPriority |
12 |
0x0040cc68 |
PVS_CaptureSamplePoint |
8 |
Notation: "push P" and "pop" are the memory manager's heap stack (slots +0xb8 / +0xc0; pop ignores its argument,
Memory); "new X (line n)" is an Alloc from the pool on top of the stack with
WorldManagerPS2.cpp and line n; "track" is TrackPool (slot +0xd8, which on retail returns the pool unchanged);
"register" is RegisterPool(name, pool, parent) (slot +0xc8).
WorldManager_CreatePools¶
0x0040d688, no arguments; boot step 21 (Boot).
- Push the global heap (manager
+0x08).L= the current pool'sLargestFreeBlock(slot+0x80). - new
MemoryPoolHeapobject (0x10 bytes, tagMemoryPoolHeap, align 16, line 121, flag 1); vtable0x00544428.MemoryPoolHeap_InitInParent(heap, global, L − 0x20000, "Sector Pool"): no minimum (anLunder 128 KB would give a negative size; inferred never to happen). Track it into0x006eb9c8; registerSector Poolunder the global heap. Pop. L2= the current pool'sLargestFreeBlock;size2 = L2 − 0x20000, raised to0x1000when smaller (a signed compare). new heap object (line 134, flag 1);InitInParent(global, size2, "Sector Pool 2"); track into0x006eb9cc; register under the global heap. No push around this second one: it is allocated from whatever pool is on top after the pop (the global heap at boot).
Sizes on a retail boot: 17,217,536 and 4,096 bytes (Sizes at runtime, confirmed (runtime) there).
WorldManager_Construct¶
0x0040d900, WorldManager* WorldManager_Construct(WorldManager* wm) on the 0x60-byte object (boot step 26).
- Zero
+0x04to+0x28and callPackDeque_InitMap(wm, 0)(0x004f4d20):+0x00-+0x28is an SGI-STLdeque<string>of pack names (start iterator+0x0c-+0x18, finish iterator+0x1c-+0x28: current, first, last, node, asWorldManager_QueuePackreads them). Zero+0x50,+0x54,+0x58(the draw list),+0x38,+0x40,+0x44,+0x48,+0x5c,+0x2c,+0x34.+0x30and+0x4care not written. size= the file system'sGetSize("warriors.glr")(slot+0x58).- new
MemoryPoolClumpobject (0x18 bytes, line 179) from the pool on top of the stack (not theSector Pool: nothing is pushed); vtable0x00544370.MemoryPoolClump_InitInParent(clump, SectorPool, size × 101 / 100, "Global Data")(32-bit unsigned multiply and divide, so the size is truncated). Track it asGlobalPoolinto+0x3c; registerGlobal Data Poolunder theSector Pool. - Open
warriors.glr(slot+0x60), wrap it in aBufferedStreamon the file manager's buffer, push+0x3c,ChunkSystem_LoadContainer, pop, detach, close (slot+0x68), destroy the stream. Returnwm.
Nothing ever frees the Global Data Pool (no other reference to +0x3c frees it; inferred from the unload below).
WorldManager_LoadLevel¶
0x0040dbb8, int WorldManager_LoadLevel(WorldManager* wm, const char* name, int headerOnly); returns 1 always.
Callers: main (0x00144778, level1 with headerOnly 1) and InitLevel (0x00160210, headerOnly 0).
snprintf(buf, 255, "%s.lev", name); printLooking for file : %s;size=GetSize(buf)(slot+0x58).- Push the
Sector Pool. new clump object (line 234).levelPool = max(size × 103 / 100, 0x40000)(unsigned, truncated; 0x40000 = 262,144).InitInParent(clump, SectorPool, levelPool, "World Level Pool"); track into+0x38; registerWorld Level Poolunder theSector Pool. Pop. - Push the
Level Dynamic & LUA Pooland leave it pushed. This function never pops it;WorldManager_Unloadwithkeep0 does (below). So during a level the Lua pool is the default pool, and the world objects of step 4 come from it. -
Unless
headerOnly, withworld= the current level record's world name (W_GameState0x0051489c, record indexu16at+0x56dc, records of 0x84 bytes from+0x14d4, name at record+0x39):- if the file system's
Exists("<world>s_sec.wld")(slot+0x50): newWorld(0x2188 bytes, tagWorld, line 251) →World_Construct→+0x44, thenWorld_ReadManifest(+0x44, "<world>s")andWorld_LoadStream(+0x44, "<world>s"); then newWorld(line 259) →+0x48, read and load"<world>d"; - otherwise
+0x48 = 0first, then newWorld(line 270) →+0x44, read and load"<world>".
A failed allocation stores null and the manifest read follows anyway (no check). 5. Push
+0x38;+0x40 = WorldLevel_Load(name)(Unit 17); pop. 6. Print the banner and theSector Pool's free bytes (slot+0x88);0x005147d0= those free bytes (written, never read);0x005147cc= 0.001 (0x3a83126f). - if the file system's
With headerOnly the worlds are skipped but the World Level Pool, the level file and the Lua push all happen.
WorldManager_Unload¶
0x0040f5b8, void WorldManager_Unload(WorldManager* wm, int keepLevelFile). Callers: main (0x00144830, keep 0,
after the boot's header-only load), UnloadLevel (0x00160b64, keep = its own argument) and Gm_Level_Exit
(0x00158554, keep 0). Mode 1's exit passes the "level change pending" flag 0x0050c754 to UnloadLevel, so a
level change keeps the level file through the outro movie, then Gm_Level_Exit unloads it with keep 0; without a
level change UnloadLevel(0) unloads everything at once (0x001584f4, 0x00158554).
Always:
- Device (
0x0050cdb4) slot+0x18: flush the render queue. Task manager (0x00512c7c) slot+0x48. - If
+0x44:World_Unload(GetSolidWorld(wm))(0x00410b70: its parts, world, dictionary and clump, The streamed world); delete the world object (0x004f57d8:World_DestroythenFree, line 1160);+0x44 = 0. - If
+0x48: the same (line 1168);+0x48 = 0. - If
+0x5c: delete the water effect (0x004f5848, line 1174);+0x5c = 0.
The deletes free into the pool on top of the stack, which is the Lua pool that LoadLevel pushed, where the objects
came from.
Only when keepLevelFile is 0:
- Pop (the Lua pool pushed by
LoadLevel). - Push the
World Level Pool(+0x38); itsSetOption(0, 1)(count frees). WorldLevel_Release(+0x40)(Unit 17): the glows, the captions, the level object and its mesh, occluders and models.- Free the path data (
0x0051058c, line 1187,0x004f58b8: if non-null). SetOption(0, 0); pop.- Push the
Sector Pool. The level clump'sCheckEmpty(slot+0x70; its result is not used);UnregisterPool(manager slot+0xd0); theSector Pool'sSetOption(0, 1)(a heap ignores it); the clump'sDestroy(Sector Pool)(slot+0x90: its block goes back); delete the 0x18-byte clump object (0x004de3d8, line 1200), whichLoadLevelallocated from theSector Pool;SetOption(0, 0); pop. +0x38 = 0.
Left behind, by both paths: +0x40 still points at the freed level object (a WorldManager_RayCast after an
unload reads freed memory; inferred that nothing casts then); the path data globals 0x0051058c and 0x00510584
keep their old values; +0x2c, +0x34, the draw list and the pack queue are not touched; the Global Data Pool is
never freed. With keep 1, the World Level Pool, the level object and the Lua push all stay.
What an unload with keep 0 gives back to the Sector Pool: the World Level Pool's block and its clump object,
and (inside step 2's World_Unload) each world's clump and every loaded part's Sectors<i> clump. To the Lua pool:
the two 0x2188-byte world objects, their 140 vector buffers (32 bytes each, Unit 19) and the
water effect. So the Sector Pool returns to what it held before LoadLevel, apart from what the resource manager
holds in it (its clumps are its own, freed by UnloadLevel's later steps).
The heap stack: LoadLevel leaves it one deeper; Unload(0) makes it one shallower; Unload(1) leaves it as
it is. Each path above runs LoadLevel once and Unload(0) once, so they pair (inferred from the three call sites;
whether a level can start again after Unload(1) without an Unload(0) is not traced).
The small ones¶
WorldManager_ResetVisibility(wm)(0x0040e0b8):+0x34 = 0;World_ResetVisibility(GetSolidWorld(wm))without a null check of+0x44; thenWorld_ResetVisibility(+0x48)when it is non-null. Callers:WorldManager_PreloadandResourceMgr_LoadAllBlocking(0x001889d0). WhatWorld_ResetVisibilityclears: Visibility.WorldManager_QueuePack(wm, name)(0x0040e1a0): appends a copy ofname(a reference-counted string, allocated through the STL pool0x006eb9c0) at the deque's back: in place when the finish node has room (finish current ≠ finish last − 4), else throughPackDeque_PushBack. No limit, no duplicate check. Only caller:World_QueuePackToPrecache(0x0040cc40).WorldManager_GetSolidWorld(wm)(0x0040e8d0): returns+0x44.WorldManager_GetSectorPool(wm, &largestFree)(0x0040f850):wmis not read; whenlargestFreeis non-null it receives theSector Pool'sLargestFreeBlock; returns theSector Pool(0x006eb9c8). Callers:GameStats_Update,Gm_Mode11_Update,ResourceMgr_Heap,ResourceMgr_ServiceDeferred,WorldManager_Update,World_RequestPart.WorldManager_SetLoadPriority(scale)(0x0040c938): stores the float argument (f12) into0x005147ccas it is: no clamp, no check, no return value. Callers:Gm_Mode11_Enter(0x0015e9d0, 10.0) and the Lua bindinglua_SetLoadPriority(0x0036e630);LoadLevelwrites 0.001 itself. The streaming update multiplies the resource manager's distances by it (Choosing what to stream).PVS_CaptureSamplePoint(0x0040cc68):jr ra(Debug).
What Coney changes¶
Coney allocates normally (Memory), so the pools become reservations in
world::SectorBudget and the heap stack has no counterpart. What a player can see is the budget, which decides
evictions, so it must come back exactly on unload.
- A
world::WorldManager(new, platform-neutral; the worlds' renderer objects stay inplatform::WorldSet) holding: theGlobal Datareservation, made once when the manager is made (globalDataPoolBytes, already 101 % truncated); theWorld Level Poolreservation (worldLevelPoolBytes); the streamed worlds; the level object; the visibility latch (+0x34); the pack queue; the load priority (initially whatever the boot leaves;LoadLevelsets 0.001).@orig 0x0040d900 WorldManager::WorldManager (WorldManagerPS2.cpp). loadLevel(name, headerOnly): reserve the level pool first, then (unless header-only) the worlds in the order above, then the level file; set the load priority to 0.001.@orig 0x0040dbb8.unload(keepLevelFile): release the worlds (and their parts) and the water always; with keep false, also run the release (glows, captions, level object), drop the path data and release the level pool's reservation. Coney clears the level pointer (where the original leaves it dangling, a marked Coney choice).@orig 0x0040f5b8.- A leak to fix now.
loadLevelScenery(src/platform/world_viewer_mode.cpp) reserves theGlobal DataandWorld Levelpools on every level load and nothing releases them (LevelPlaySceneryandWorldSetrelease only the worlds and parts), so a session that plays a second level (the one budget made inmain.cpp) streams with less room than the original. The manager above fixes it; until then, release both when the scenery goes. setLoadPriority(float)stores the value unchanged; it has no reader until the resource manager streams (a stand-in).@orig 0x0040c938.queuePack(name)appends to astd::deque<std::string>; the preload consumes it (world_streamer's preload).@orig 0x0040e1a0.resetVisibility()clears the latch and callsStreamedWorld::resetVisibilityon each world. Coney'sStreamedWorldcannot be null, so the original's missing null check has nothing to copy.@orig 0x0040e0b8.sectorPool(&largestFree): the budget, withfreeBytes()standing for the largest free block (the approximation Memory recommends).@orig 0x0040f850.CreatePools:kSectorPoolSize(the retail runtime value) already stands for it; tag the constant@orig 0x0040d688 WorldManager_CreatePools (WorldManagerPS2.cpp).Sector Pool 2has nothing to stand for.
Test ideas¶
With a SectorBudget of 17,217,536 bytes and synthetic file sizes:
- Pool sizes:
worldLevelPoolBytes(100,000)= 262,144;(254,500)= 262,144 (262,135 raised);(254,524)= 262,159;(300,000)= 309,000.globalDataPoolBytes(99)= 99 (9,999 / 100 truncated);(1,000)= 1,010.Sector Pool 2forL2= 100,000: 4,096; for 200,000: 68,928. - Load then unload(0): with the global data reserved,
used()beforeloadLevelequalsused()afterunload(false), for a two-world level and for a header-only load; the level object is gone. - Unload(1): after
unload(true)the worlds are released and exactly the level pool's reservation remains; a followingunload(false)releases it. - Twice: load, unload(0), load, unload(0) leaves
used()where it started (the leak above fails this today). - ResetVisibility: with marks set in both worlds and the latch set, all marks and the latch are clear afterwards.
- Load priority:
setLoadPriority(10.0)then reading gives 10.0;-3.0gives -3.0 and1e9gives 1e9 (no clamp);loadLevelsets 0.001. - Pack queue: three
queuePackcalls with the same name give three entries, in order.
Unit 19: Streamed world object lifecycle and sector callbacks¶
What it is. The world object's constructor and destructor, the part-loaded callback, the release of one sector's atomic, the material alpha callback, the visibility pass's sector callback and the modulate flag. Confirmed (code) at the cited addresses unless a line says otherwise.
- Coney:
src/world/streamed_world.*(the visibility callback's order and its quirk),src/platform/world_set.*(the release of an atomic, the modulate flag, tags),src/platform/world_renderer.*(the alpha callback). - Tests: Test ideas: a marked sector of the
dworld sets thesworld's mark; an occluded sector is neither marked nor collected nor counted; a sector without an index is neither collected, marked nor counted; the alpha callback changes only the alpha byte of every material; a sector whose atomic is in use stays pending.
7 functions, 1684 bytes (0 tagged).
| Address | Name | Size |
|---|---|---|
0x00410308 |
World_Construct |
588 |
0x00410558 |
World_Destroy |
236 |
0x004114d8 |
World_PartLoaded |
248 |
0x004118b8 |
WorldSector_ReleaseAtomic |
156 |
0x00411958 |
Material_SetAlphaCallback |
56 |
0x00411b98 |
World_VisibilitySectorCallback |
376 |
0x004102f0 |
Atomic_SetModulateMaterialColour |
24 |
Records: the world object (The world object) and the sector plugin (offset in 0x0050ced0;
+0x00 atomic, +0x04 streamed index or -1, +0x08 state, Sector plugin).
World_Construct¶
0x00410308, World* World_Construct(World* world); no vtable. Writes, in this order:
- The visible bits
+0x08f8: 18 words = 0. - The 140 part-sector vectors at
+0x0940(16 bytes each:+0x04begin,+0x08end,+0x0ccapacity end;+0x00unused): all three pointers = 0, then each reserves 8 entries: a 0x20-byte buffer from the STL pool (0x006eb9c0, theLevel Dynamic & LUA Pool's STL copy; align 16), begin = end = buffer, capacity end = buffer + 0x20. So a world takes 140 × 32 = 4,480 bytes of the Lua pool besides itself. +0x10 = 0,+0x04 = -1,+0x08 = 0,+0x2158to+0x2168(dictionary,RpWorld, has-PVS, part count, streamed-sector count) = 0,+0x00 = -1,+0x0c = 0,+0x14 = 0,+0x2170 = 0,+0x216c = 0,+0x2150 = 0.- The 140 part records at
+0x1200(0x1c bytes each): all 7 words = 0.
Not written: +0x18-+0x37 (cleared by the first viewport's pass), the sector table +0x38-+0x8f7, the
world's clump +0x2154 and its name +0x2174. The memory is the Lua heap's, not cleared. World_FindPartToLoad
(0x00411eb0) reads every table entry from 0 to the streamed-sector count − 1 without a null test, so every index
must be filled by World_RegisterSector (inferred from the disc check that indices run 0 to k − 1 without gaps,
Sector plugin).
World_Destroy¶
0x00410558, void World_Destroy(World* world, u32 flags), called through 0x004f57d8 by WorldManager_Unload
after World_Unload:
- For each of the 140 vectors, first to last: end = begin (the entries are plain pointers; nothing is destroyed).
- For each vector from the last to the first: if begin is non-null, free it to the STL pool.
operator delete(world)only whenflags & 1.
It releases nothing else: the parts, the RpWorld, the dictionary and the clump are World_Unload's
(Choosing what to stream).
World_PartLoaded¶
0x004114d8, the file manager's completion callback for a part read, (request, World* world); the first argument
is not read.
- Push the
Level Dynamic & LUA Pool. - Wrap the file manager's buffer (
+0x3c) as an in-memory file whose size and capacity are both the part record's file size: recordworld+0x1200 + 0x1c × p,+0x10, withp = world+0x00(the part being read). - A RenderWare custom stream over it (
0x0047f1f8(4, 1, …): custom type, read access; RenderWare's constants, inferred). World_ReadPart(world, stream)(0x004110c0, Part file), then close the stream.- Pop.
world+0x00 = -1(no part being read). Destroy the in-memory file.
No step checks the read's status or World_ReadPart's result.
WorldSector_ReleaseAtomic¶
0x004118b8, RpWorldSector* WorldSector_ReleaseAtomic(RpWorldSector* sector, void* unused), called for each sector
of a part by World_UnloadPart (0x004115d0, with a null second argument):
- Plugin state (
+0x08) = 3. atomic= plugin+0x00. If it is null, orAtomic_IsInUse(atomic)(0x00198220) is true: returnsector, state left at 3.- Otherwise:
frame=atomic+0x04; detach the frame (0x0046a0c0(atomic, 0)); destroy the frame (0x00484240); destroy the atomic (0x0046a3f0); plugin atomic = null; state = 4. Returnsector.
World_UnloadPart goes through the part's sector vector and stops at the first sector not at 4; the part is
finished (dictionary destroyed, world+0x04 = -1, clump checked, unregistered and destroyed) only when every sector
reached 4, else the unload is tried again on a later frame. So a sector with no atomic in a part being unloaded
would hold its part forever (inferred: a loaded part's sectors all have atomics, Part file).
What Atomic_IsInUse tests (the atomic's MatFX or particle-tank users) is not read further here.
Material_SetAlphaCallback¶
0x00411958, RpMaterial* Material_SetAlphaCallback(RpMaterial* material, const u8* alpha): copies the material
colour (+0x04, four bytes RGBA, read and written unaligned), replaces byte 3 with *alpha, writes it back and
returns material (non-null, so a ForAllMaterials walk goes on to the next). R, G and B are unchanged.
World_RenderSectorAtomic runs it over every material of a sector's atomic during the fade-in (A
frame).
Atomic_SetModulateMaterialColour¶
0x004102f0, RpAtomic* Atomic_SetModulateMaterialColour(RpAtomic* atomic): geometry = atomic+0x18; geometry
flags (+0x08) |= 0x40 (modulate by material colour); returns atomic. No null test of the geometry. Applied to
every atomic of a part as it is read (Part file, step 4), which is what lets the alpha above
fade it.
World_VisibilitySectorCallback¶
0x00411b98, RpWorldSector* World_VisibilitySectorCallback(RpWorldSector* sector), installed as the world's sector
render callback by World_FindVisibleSectors (0x00411d10) for the length of one RpWorldRender; the world's own
callback is saved in 0x005147e4 and the world being walked in 0x005147ec.
- PVS:
visible= 1. When the walked world has PVS data (+0x2160) and PVS is on (0x005e539c),visible=0x004bef30(sector)(RenderWare's PVS test, inferred from its place). - Unindexed sectors: if the plugin's streamed index (
+0x04) is -1 (the value at0x00553790), skip to step 6. - Occluders: build a box from the sector's bounding box,
(+0x6c, +0x70, +0x74, 1)then(+0x60, +0x64, +0x68, 1)(RenderWare'sinfthensup, inferred from theRwBBoxlayout); ifOccluders_HidesBox(level+0x28, box)(0x0017a738, the level from world manager+0x40) returns 1, return null at once. - Count:
0x005147f4 += 1, before and whatever the PVS answer. - Keep: if
visible: call the saved callback (World_CollectSector0x00411b20, which queues the sector when its atomic is loaded), then set bitindexin the visible bits ofGetSolidWorld(wm), thesworld (+0x08f8 + 4 × (index >> 5), bitindex & 31), whichever world is being walked. - Return
sectorwhenvisible, else null.
Consequences (confirmed (code) for each mechanism, the effects inferred): the d world's own visible bits are only
ever cleared (by World_ResetVisibility on the first viewport), so its World_FindPartToLoad never finds a visible
candidate and always falls back to all its sectors (its +0x10 is always set after a search), and its
World_FindPartToUnload treats every loaded part as unseen. The s world's bits gain the d world's indices, so an
s sector can count as visible because the d sector with the same index is. The counter 0x005147f4 is reset by
World_ResetVisibility (0x004123e8, the store at 0x004124e8) and read once by World_FindVisibleSectors
(0x00411e20): when it is still 0 after a PVS walk, the walk is repeated with PVS off. Since PVS-hidden sectors count,
the repeat happens only when no indexed sector survived the occluders (or none was walked), not when the PVS hid them
all.
Whether RenderWare's walk stops or goes on after a null return is RenderWare's, not read here; the original returns null for both an occluded and a PVS-hidden sector.
What Coney changes¶
- The visibility quirk.
StreamedWorld::markVisiblemarks the world's own sector. To match the original's streaming order, the pass over thedworld must mark thesworld's bit with thedsector's index, and thedworld's marks stay clear: an "Original quirk, kept" infindVisibleSectors(a pass needs thesworld as the mark target), with a test. Single-world levels are unaffected. - Order inside the callback. When Coney adds occluders, the test comes after the index check and before the
count; when it adds PVS, the PVS answer only decides marking and collecting. Until then the frustum test stands for
both (already marked in
streamed_world.h). - Unindexed sectors are not collected by the original (the saved callback runs only in the indexed branch,
0x00411c9c; the sector is only returned to RenderWare when visible); Coney'scollectSectorsskips them too. - Releasing an atomic.
WorldSet::unloadPartfrees a part at once. The original keeps a sector at state 3 while its atomic is in use and retries the part later; Coney has no in-use atomics yet (a stand-in, to mark), and a pending state should be added with whatever first holds an atomic.@orig 0x004118b8 WorldSector_ReleaseAtomic (WorldPS2.cpp)on the per-atomic release inunloadPart. - Part loaded.
WorldSet::loadPartreads synchronously and stands for both the request's completion andWorld_PartLoaded; tag it@orig 0x004114d8 World_PartLoaded (WorldPS2.cpp)besideWorld_ReadPart. - Construct and destroy.
StreamedWorld::createand~WorldSetstand for them; Coney sizes from the data, so the 140 reserved vectors have no counterpart. Tags:@orig 0x00410308 World::World,@orig 0x00410558 World_Destroy(bothWorldPS2.cpp). - Alpha and modulate.
loadPartalready sets geometry flag0x40(tag@orig 0x004102f0 Atomic_SetModulateMaterialColour (WorldPS2.cpp)); the renderer's fade sets the alpha of every material and must leave R, G and B alone (tag@orig 0x00411958 Material_SetAlphaCallback (WorldPS2.cpp)).
Test ideas¶
On a synthetic two-world layout (no renderer needed for the first four):
- The
sworld takes the marks: walking thedworld with its sector of index 5 in view sets index 5 in thesworld's marks and leaves thedworld's clear;findSectorToLoadon thedworld then reportssearchFellBack(). - Occluded: a sector of index 3 whose box an occluder hides is not marked, not collected, and does not raise the counter; one not hidden but PVS-hidden raises the counter and is neither marked nor collected.
- Unindexed: a sector with index -1 in view is not marked and not counted.
- Repeat without PVS: when every indexed sector in view is occluded, the pass runs again with PVS off (counter still 0); when all are only PVS-hidden, it does not.
- Alpha: materials with colours (10, 20, 30, 255) and (200, 100, 50, 7), alpha 128: afterwards (10, 20, 30, 128) and (200, 100, 50, 128).
- Release: a part of three sectors whose second atomic is in use: the first is released (state 4), the second stays at 3, the third is not visited, the part is not finished; once the use ends, a retry finishes it.