diff --git a/notes/geometry.md b/notes/geometry.md index 23827b2..31381ca 100644 --- a/notes/geometry.md +++ b/notes/geometry.md @@ -94,7 +94,7 @@ and are set on load. ![top and side view of the needle triangle next to the crate](img/floor_explained_t1l4m002.png) -The crate has two such needles, one per long side: poly 15 (z 43.68–43.73, measured live) and poly 11 (z 43.29–43.38, same shape, where a user got stuck). Drawn with `tools/floorviz.py`. +The crate has two such needles, one per long side: poly 15 (z 43.68–43.73, measured live) and poly 11 (z 43.29–43.38, same shape, where a user got stuck). Drawn with `tools/floorre/explain_t1l4m002.py` (builds on the floor-test agent's plot). (2026-11-08, from the decompile + objdump of 0x4036d0–0x403ad0, a bit-exact Python re-implementation `tools/floortest.py`, and live reads in game. [certain] unless marked.) diff --git a/notes/img/floor_explained_t1l4m002.png b/notes/img/floor_explained_t1l4m002.png index 5362bea..2cd12a5 100644 Binary files a/notes/img/floor_explained_t1l4m002.png and b/notes/img/floor_explained_t1l4m002.png differ diff --git a/tools/floorre/corners.py b/tools/floorre/corners.py new file mode 100644 index 0000000..b19664f --- /dev/null +++ b/tools/floorre/corners.py @@ -0,0 +1,25 @@ +import sys,struct,numpy as np; sys.path.insert(0,'.'); from floor import * +t=443; x,y,z,rot,mi,_=mp['A'][t]; recs=[r for g in trak[mi].coll for r in g] +def planes(r): + return [struct.unpack_from(' n127.p = d/32 (p in units)... + return pts +# local (engine) -> world: local=(lz_w', -lx_w') for rot 3072 => dx=-lz, dz=lx +a=-rot/4096*2*math.pi +def toworld(p): + lx,ly,lz=p + # invert lx=c*dx+s*dz, lz=c*dz-s*dx with s=sin(a'),c=cos(a'), a'=(-rot)*2pi/4096 + s,c=math.sin(a),math.cos(a) + dx=c*lx-s*lz; dz=s*lx+c*lz + return (x+dx,y+ly,-z+dz) +for i in [int(v) for v in sys.argv[1:]]: + r=recs[i]; P=planes(r) + print('poly',i,'type',r[0],'surface n',[v/127 for v in P[0][:3]]) + for p in corners(r): + + print(' local',np.round(p,4),' world',np.round(toworld(p),4)) diff --git a/tools/floorre/explain_t1l4m002.py b/tools/floorre/explain_t1l4m002.py new file mode 100644 index 0000000..66f0493 --- /dev/null +++ b/tools/floorre/explain_t1l4m002.py @@ -0,0 +1,137 @@ +# Usage: python3 tools/floorre/explain_t1l4m002.py tools/floorre OUT.png (WAD via env WAD, default t1l4m002 path in floor.py; needs Pillow, numpy) +# Explainer: Kuzco floating next to the crate in t1l4m002 (builds on floorre/plot.py) +import sys, math +sys.path.insert(0, sys.argv[1]) # floorre dir (floor.py, corners.py) +OUT = sys.argv[2] +sys.argv = sys.argv[:1] +from floor import * # mp, trak, floor_test +from corners import corners +from PIL import Image, ImageDraw, ImageFont +SS = 2 # supersampling +F = lambda n, b=False: ImageFont.truetype('DejaVuSans-Bold.ttf' if b else 'DejaVuSans.ttf', n * SS) +K = (57.204, 2.474, 43.733) +T = 443 +TX, TY, TZ, ROT, MI, _ = mp['A'][T] +recs = [r for g in trak[MI].coll for r in g] + +def xf(B): + rot = B[1]; tx, ty, tz = B[3] / 4096, B[4] / 4096, B[5] / 4096 + a = -rot / 4096 * 2 * math.pi; s, c = math.sin(a), math.cos(a) + return lambda p: (tx + c * p[0] - s * p[2], ty + p[1], tz + s * p[0] + c * p[2]) + +RED, BLUE, MAG, PINK = (215, 25, 25), (20, 90, 230), (210, 0, 210), (255, 70, 150) + +def panel(X0, Z0, span, size, detail): + S = size / span + img = Image.new('RGB', (size * SS, size * SS), 'white'); d = ImageDraw.Draw(img, 'RGBA') + P = lambda x, z: ((x - X0) * S * SS, (size - (z - Z0) * S) * SS) + # rendered terrain coloured by height (like the agent's plot) + for t in range(mp['N']): + B = mp['B'][t] + if not (X0 - 2 <= B[3] / 4096 <= X0 + span + 2 and Z0 - 2 <= B[5] / 4096 <= Z0 + span + 2): continue + m = trak[mp['midx'][t]]; f = xf(B) + for fc in sorted(m.faces, key=lambda fc: sum(m.verts[v][1] for v in fc['v'])): + Q = [f((m.verts[v][0], m.verts[v][1], -m.verts[v][2])) for v in fc['v']] + h = sum(p[1] for p in Q) / len(Q); g = int(max(0, min(255, (h - 1.5) / 2 * 255))) + d.polygon([P(p[0], p[2]) for p in Q], fill=(g, 200 - g // 2, 255 - g, 255), + outline=(130, 130, 130) if not detail else None) + # cell grid + for k in range(int(X0) - 1, int(X0 + span) + 2): + d.line([P(k, Z0 - 1), P(k, Z0 + span + 1)], fill=(0, 140, 0), width=SS * (2 if detail else 1)) + for k in range(int(Z0) - 1, int(Z0 + span) + 2): + d.line([P(X0 - 1, k), P(X0 + span + 1, k)], fill=(0, 140, 0), width=SS * (2 if detail else 1)) + return img, d, P, S + +def collision(d, P, labels, sliver=None, fsz=12): + f = xf(mp['B'][T]) + if sliver: + for (x, z, w, i) in sliver: + a, b = P(x, z), P(x + w, z - w) + d.rectangle([a[0], a[1], b[0], b[1]], fill=PINK + (170,)) + for i, r in enumerate(recs): + C = [f(p) for p in corners(r)] + col = RED if i in (11, 15) else BLUE if i == 2 else (20, 20, 20) + wid = 3 if i in (2, 11, 15) else 1 + pts = [P(p[0], p[2]) for p in C] + if i in (11, 15): d.polygon(pts, fill=RED + (70,)) + d.line(pts + pts[:1], fill=col, width=wid * SS) + if labels and (labels == 'all' or i in labels): + cx = sum(p[0] for p in pts) / len(pts); cy = sum(p[1] for p in pts) / len(pts) + txt = str(i) if labels == 'all' else f'poly {i} · mat {r[1]}' + d.text((cx, cy), txt, fill=col, font=F(fsz, i in (2, 11, 15)), anchor='mm', + stroke_width=SS * 2, stroke_fill='white') + +def sliver_cells(x0, z0, x1, z1, step): + out = []; tx, ty, tz = int(round(TX * 4096)), int(round(TY * 4096)), int(round(-TZ * 4096)) + z = z0 + while z < z1: + x = x0 + while x < x1: + if int(x) == 57 and int(z) == 43: + _, res = floor_test(int(round(x * 4096)), int(round(2.27 * 4096)), int(round(z * 4096)), tx, ty, tz, ROT, recs) + best = None + for i, i7, ok, vals, tol, Y in res: + if ok and i7 >= -0x80 and (best is None or i7 < best[0]): best = (i7, i) + if best and best[1] in (11, 15): out.append((x, z, step, best[1])) + x += step + z += step + return out + +W = 1500 +# --- left: overview (3 x 3 cells) --- +ov, d, P, S = panel(56.0, 42.0, 3.0, 640, False) +collision(d, P, 'all', fsz=11) +for t in range(mp['N']): + B = mp['B'][t]; x, z = B[3] / 4096, B[5] / 4096 + if 56 <= x <= 59 and 42 <= z <= 45: + d.text(P(x - 0.45, z - 0.38), f'tile {t}', fill=(110, 0, 140), font=F(12, True), stroke_width=SS * 2, stroke_fill='white') +k = P(K[0], K[2]); d.ellipse([k[0] - 7 * SS, k[1] - 7 * SS, k[0] + 7 * SS, k[1] + 7 * SS], fill=MAG, outline='black', width=SS) +ZX0, ZZ0, ZSP = 57.06, 43.16, 0.76 +a, b = P(ZX0, ZZ0 + ZSP), P(ZX0 + ZSP, ZZ0) +d.rectangle([a, b], outline=(255, 140, 0), width=3 * SS) +ov = ov.resize((640, 640), Image.LANCZOS) + +# --- right: zoom on the crate's side with both needles --- +zm, d, P, S = panel(ZX0, ZZ0, ZSP, 820, True) +collision(d, P, [2, 11, 15], sliver=sliver_cells(ZX0, ZZ0, ZX0 + ZSP, ZZ0 + ZSP, 0.0012), fsz=15) +k = P(K[0], K[2]); r = 9 * SS +d.ellipse([k[0] - r, k[1] - r, k[0] + r, k[1] + r], fill=MAG, outline='black', width=2 * SS) +d.text((k[0], k[1] + 30 * SS), 'Kuzco\n57.204 / 43.733\n(measured live)', fill=MAG, font=F(15, True), anchor='ma', + align='center', stroke_width=SS * 2, stroke_fill='white') +tip = P(57.315, 43.729) +y = tip[1] - 34 * SS +d.line([(k[0], y), (tip[0], y)], fill='black', width=2 * SS) +for xq in (k[0], tip[0]): d.line([(xq, y - 8 * SS), (xq, y + 8 * SS)], fill='black', width=2 * SS) +d.text(((k[0] + tip[0]) / 2, y - 6 * SS), '0.11 outside poly 15', fill='black', font=F(14, True), anchor='md', + stroke_width=SS * 2, stroke_fill='white') +d.text((P(57.38, 43.80)), 'needle tips 13.5°', fill=RED, font=F(14, True), stroke_width=SS * 2, stroke_fill='white') +d.text((P(ZX0 + 0.01, ZZ0 + 0.035)), 'pink = where the engine still takes the needle as floor\n(edge tolerance ≈ 0.02 → sliver ≈ 0.19 long, 0.01 wide)', + fill=(190, 20, 110), font=F(14, True), stroke_width=SS * 2, stroke_fill='white') +zm = zm.resize((820, 820), Image.LANCZOS) + +# --- bottom: side view --- +sv = Image.new('RGB', (W * SS, 300 * SS), 'white'); d = ImageDraw.Draw(sv) +H = lambda v: (270 - (v - 2.2) * 600) * SS +x0, x1 = 80 * SS, 900 * SS +d.line([(x0, H(2.270)), (x1, H(2.270))], fill=BLUE, width=6 * SS) +for i in range(x0, x1, 28 * SS): d.line([(i, H(2.474)), (i + 14 * SS, H(2.474))], fill=RED, width=5 * SS) +kx = 300 * SS +d.rectangle([kx - 16 * SS, H(2.474 + 0.12), kx + 16 * SS, H(2.474)], outline=MAG, width=3 * SS) +d.text((kx + 24 * SS, H(2.474 + 0.06)), "Kuzco's feet", fill=MAG, font=F(15, True), anchor='lm') +d.line([(560 * SS, H(2.474)), (560 * SS, H(2.270))], fill='black', width=2 * SS) +d.text((572 * SS, (H(2.474) + H(2.270)) / 2), '0.20 in the air', fill='black', font=F(17, True), anchor='lm') +d.text((x1 + 20 * SS, H(2.474)), 'poly 15 plane extended to Kuzco: y 2.474 ← chosen', fill=RED, font=F(16, True), anchor='lm') +d.text((x1 + 20 * SS, H(2.270)), 'poly 2 (path) really under Kuzco: y 2.270', fill=BLUE, font=F(16, True), anchor='lm') +d.text((20 * SS, 14 * SS), 'Side view at Kuzco\'s position: the game takes the highest passing floor up to feet + 0.25', fill='black', font=F(20, True)) +sv = sv.resize((W, 300), Image.LANCZOS) + +out = Image.new('RGB', (W, 110 + 820 + 300 + 20), 'white') +d = ImageDraw.Draw(out) +d.text((20, 14), 'Why Kuzco stands in the air next to the crate · Jungle Day 2 (t1l4m002), tile 443', fill='black', font=ImageFont.truetype('DejaVuSans-Bold.ttf', 26)) +d.text((20, 54), 'Top view, colours = terrain height, green lines = grid cells, numbers = collision polygons of tile 443 (all material 0). ' + 'Red = the two needle triangles 11 and 15 (steep crate bevels),\nblue = poly 2 (the path). Units = grid cells; Kuzco is about 0.6 tall.', + fill=(60, 60, 60), font=ImageFont.truetype('DejaVuSans.ttf', 15)) +out.paste(ov, (20, 110 + 90)); out.paste(zm, (680, 110)) +d.text((20, 110 + 60), 'Overview (3 × 3 cells), orange box = zoom →', fill='black', font=ImageFont.truetype('DejaVuSans-Bold.ttf', 17)) +out.paste(sv, (0, 110 + 820 + 20)) +out.save(OUT); print('saved', OUT, out.size) diff --git a/tools/floorre/floor.py b/tools/floorre/floor.py new file mode 100644 index 0000000..9eb3108 --- /dev/null +++ b/tools/floorre/floor.py @@ -0,0 +1,40 @@ +import sys,struct,math; sys.path.insert(0,'/home/elias/projects/llamamod/tools'); import geomlib as G +ch,wf,trak,mp=G.load_level(__import__('os').environ.get('WAD','/home/elias/.claude/jobs/90c6eed7/tmp/exitre/t1l4m002.wad')) +_exe=open('/home/elias/projects/llamamod/re/groove.exe','rb').read() +SIN=list(struct.unpack_from('<4096i',_exe,0x174318)) +def c8(b): return b-256 if b>127 else b +def floor_test(px,py,pz,tx,ty,tz,rot,recs,dbg=False,probe_add=0x400): + """exact re-implementation of FUN_004036d0 floor branch (flags 0x200000 off). ints 20.12""" + lx=px-tx; lz=pz-tz; ly=py-ty+probe_add + u=(-rot)&0xffffffff + if u: + s=SIN[u&0xfff]; c=SIN[(u+0x400)&0xfff] + lx,lz=(s*lz+c*lx)>>12,(c*lz-s*lx)>>12 + out=[] + for i,r in enumerate(recs): + if r[1]==0x11: continue + ny=c8(r[3]); n14=ny*32 + if n14<=0: continue + h=(((struct.unpack_from('=0)==(n14>=0) else -q # C truncating division + i7=-i6 + Y=ly+i6 + tol=-0x40-((ny*-0x20+0x1000)>>6) + ne=4 if r[0]&1 else 3 + vals=[] + ok=True + for k in range(ne): + nx,nyy,nz,_,d=struct.unpack_from('>12)-d + vals.append(v) + if v=-0x80: print(f'poly {i:2d} dist {i7/4096:+.4f} planeY {Y/4096:.4f} pass={ok} edges {vals} tol {tol}') diff --git a/tools/floorviz.py b/tools/floorviz.py deleted file mode 100644 index 2517338..0000000 --- a/tools/floorviz.py +++ /dev/null @@ -1,129 +0,0 @@ -# Draws notes/img/floor_explained_t1l4m002.png: floorviz.py (needs Pillow) -import sys -sys.path.insert(0, '/home/elias/projects/llamamod/tools') -import geomlib as G, floortest as F -from PIL import Image, ImageDraw, ImageFont - -WAD = sys.argv[1]; OUT = sys.argv[2] -ch, wf, trak, mp = G.load_level(WAD) -T = 443 -x, y, z, rot, mi, _ = mp['A'][T] -f, _ = G.tile_xform(x, y, z, rot) -b = mp['B'][T] -recs = [r for g in trak[mp['midx'][T]].coll for r in g] - -# world polygons (x, height, z) of up-facing floors of tile 443 -polys = [] -k = 0 -for g, grp in enumerate(trak[mi].coll): - for r in grp: - c = G.coll_decode(r); pts = G.coll_polygon(c) - if len(pts) >= 3 and c['plane'][1] > 0.2: - W = [f(*p) for p in pts]; W = [(p[0], p[1], -p[2]) for p in W] - polys.append((k, c['mat'], c['plane'][1], W)) - k += 1 - -KX, KY, KZ = 57.204, 2.474, 43.733 -X0, X1, Z0, Z1 = 56.98, 57.86, 43.56, 43.86 -S = 1700 # px per unit -PADL, PADT = 70, 120 -w = int((X1 - X0) * S) + PADL + 40; h = int((Z1 - Z0) * S) + PADT + 60 -def P(px, pz): return (PADL + (px - X0) * S, PADT + (Z1 - pz) * S) - -img = Image.new('RGB', (w, h), 'white') -d = ImageDraw.Draw(img, 'RGBA') -try: - font = ImageFont.truetype('DejaVuSans.ttf', 18); fb = ImageFont.truetype('DejaVuSans-Bold.ttf', 20); fs = ImageFont.truetype('DejaVuSans.ttf', 15); ft = ImageFont.truetype('DejaVuSans-Bold.ttf', 24) -except Exception: - font = fb = fs = ft = ImageFont.load_default() - -# accepted region of poly 15 at path height (exact engine test), sampled -ly = int(round((2.27 + 0) * 4096)) - b[4] + F.PROBE -step = 0.0015 -zz = Z0 -while zz < Z1: - xx = X0 - while xx < X1: - px, pz = int(round(xx * 4096)), int(round(zz * 4096)) - if (px >> 12, pz >> 12) == (b[3] >> 12, b[5] >> 12): - lx, lz = F.to_local(px, pz, b) - best = None - for i, r in enumerate(recs): - if r[1] == 0x11: continue - res = F.test_poly(r, lx, ly, lz) - if res is None or res[0] < -0x80: continue - if res[2] and (best is None or res[0] < best[0]): best = (res[0], i) - if best and best[1] == 15: - a = P(xx, zz); d.rectangle([a[0], a[1] - step * S, a[0] + step * S, a[1]], fill=(255, 120, 160, 150)) - xx += step - zz += step - -# polygons -for k, mat, ny, W in polys: - pts = [P(p[0], p[2]) for p in W] - if k == 15: col, wid, fill = (210, 0, 0), 4, (255, 0, 0, 40) - elif k == 2: col, wid, fill = (0, 90, 220), 3, (0, 90, 220, 25) - else: col, wid, fill = (150, 150, 150), 2, None - if fill: d.polygon(pts, fill=fill) - d.line(pts + [pts[0]], fill=col, width=wid) - cx = sum(p[0] for p in pts) / len(pts); cy = sum(p[1] for p in pts) / len(pts) - if X0 < sum(p[0] for p in W) / len(W) < X1 and Z0 < sum(p[2] for p in W) / len(W) < Z1: - lab = f'poly {k} · mat {mat}' - if k in (2, 15) or True: - d.text((cx - 50, cy - 10), lab, fill=col if k in (2, 15) else (110, 110, 110), font=fb if k in (2, 15) else fs) - -# needle tip and Kuzco -TIPX, TIPZ = 57.315, 43.729 -t = P(TIPX, TIPZ); kp = P(KX, KZ) -d.ellipse([kp[0] - 9, kp[1] - 9, kp[0] + 9, kp[1] + 9], fill=(200, 0, 200), outline='black') -d.text((kp[0] - 70, kp[1] + 14), 'Kuzco\nx 57.204 z 43.733', fill=(150, 0, 150), font=fb) -d.ellipse([t[0] - 5, t[1] - 5, t[0] + 5, t[1] + 5], fill=(210, 0, 0)) -d.text((t[0] + 8, t[1] - 52), 'needle tip (13.5°)', fill=(210, 0, 0), font=font) -# distance Kuzco -> tip -d.line([kp[0], kp[1] - 40, t[0], t[1] - 40], fill='black', width=2) -for xq in (kp[0], t[0]): d.line([xq, kp[1] - 48, xq, kp[1] - 32], fill='black', width=2) -d.text(((kp[0] + t[0]) / 2 - 60, kp[1] - 70), '0.11 outside poly 15', fill='black', font=font) -# sliver length -s0 = P(57.125, 43.70) -d.line([s0[0], s0[1] + 55, t[0], s0[1] + 55], fill=(200, 60, 110), width=2) -for xq in (s0[0], t[0]): d.line([xq, s0[1] + 47, xq, s0[1] + 63], fill=(200, 60, 110), width=2) -d.text((s0[0] + 10, s0[1] + 62), 'invisible sliver: ~0.19 long, ~0.01 wide', fill=(200, 60, 110), font=font) - -# title / legend -d.text((PADL, 18), 'Jungle Day 2 (t1l4m002) · tile 443 · top view of the floor collision next to the crate', fill='black', font=ft) -d.text((PADL, 56), 'red = poly 15 (steep needle triangle, mat 0) blue = poly 2 (path, mat 0) grey = other floor polygons ' - 'pink = where the engine still accepts poly 15 (edge tolerance)', fill=(60, 60, 60), font=fs) -d.text((PADL, 80), 'x →, z ↑, 1 unit = 1 grid cell (Kuzco is ~0.6 tall)', fill=(60, 60, 60), font=fs) - -# side view -top = img -img = Image.new('RGB', (w, 520), 'white') -d = ImageDraw.Draw(img, 'RGBA') -oy = 30 -d.text((PADL, oy - 10), 'Side view at Kuzco\'s position: which height the game picks', fill='black', font=ft) -base = oy + 440; hs = 520 -def H(v): return base - (v - 2.15) * hs -xl, xr = PADL + 60, PADL + 900 -d.line([xl, H(2.270), xr, H(2.270)], fill=(0, 90, 220), width=5) -d.text((xr + 10, H(2.270) - 12), 'poly 2 (path) under Kuzco: height 2.270', fill=(0, 90, 220), font=font) -d.line([xl, H(2.270) + 150 * 0, xl, H(2.270)], fill='white') -# extended plane of poly 15 (slope ny 0.51 -> steep), drawn as dashed line -for i in range(0, 840, 24): - d.line([xl + i, H(2.474), xl + i + 12, H(2.474)], fill=(210, 0, 0), width=4) -d.text((xr + 10, H(2.474) - 12), 'poly 15 plane extended to Kuzco: 2.474 ← chosen (highest)', fill=(210, 0, 0), font=font) -d.line([xl + 450, H(2.474), xl + 450, H(2.270)], fill='black', width=2) -d.text((xl + 460, (H(2.474) + H(2.270)) / 2 - 10), '0.20 in the air', fill='black', font=fb) -pr = 2.270 + 0.25 -for i in range(0, 840, 10): - d.point((xl + i, H(2.474 + 0.25)), fill=(120, 120, 120)) -d.text((xr + 10, H(2.474 + 0.25) - 10), 'probe = feet + 0.25 (floors up to here count)', fill=(110, 110, 110), font=fs) -# kuzco figure -kx = xl + 300 -d.rectangle([kx - 18, H(2.474 + 0.6), kx + 18, H(2.474)], outline=(200, 0, 200), width=3) -d.text((kx - 30, H(2.474 + 0.6) - 26), 'Kuzco (~0.6)', fill=(150, 0, 150), font=font) -side = img -out = Image.new('RGB', (w, top.height + side.height + 10), (200, 200, 200)) -out.paste(top, (0, 0)); out.paste(side, (0, top.height + 10)) -out.save(OUT) -img = out -print('saved', OUT, img.size)