re: the destructible-subsystem model -- a unit's sub-records

The corpus has named these since unit-struct-runtime.md but never
opened them.  Per unit table: Turret_NNN 835 records (max 63 on one
unit), ShieldGenerator_NNN 46, Thruster_NNN 38, Hatch_NNN 26,
Bridge_NNN 25, plus one each of Shield/Mass/SE/Explosion/
StructureCount and NS_Body on 68 of 114.

Turret/ShieldGenerator/Thruster/Hatch/Bridge are ONE record shape: a
shared 19-field destructible-part base (ID, Name, ParentStructureID,
Frame = a mesh NODE name, NomalModel, CollisionModel, Radius, HP, the
four Is* flags, SpreadDamage, damaged/destroy motion + time, and the
three Effect_*), with per-kind extras.  Turrets add WeaponID,
AngularVelocity, YawLimit, PitchLimit_Elevation/_Depression, CoverArea,
IsAuto, HasBarrel and up to 80 CannonModel_NNN/CannonFrame_NNN.  Shield
generators, thrusters and bridges add PowerRatio.  Hatches add
SquadronID, LoadedCount, MaxAvailableCount, TakeoffInterval -- a
carrier's launch bay.

Control 1: StructureCount.<Kind>Count == #<Kind>_NNN records, over 684
comparisons -- 612 equal, 55 "0 declared, one blank placeholder"
(55/55 blank in Name AND NomalModel AND Frame), 11 differ, 6 kind
absent.  All 11 exceptions are Turret and all are declared < records.

Control 2: 835/835 Turret_NNN.WeaponID resolve to an ID in the
131-record Weapon datasheet, zero unresolved; 26 weapons are never
mounted on a turret.

Refuted in the same pass: "the DeltaSaber's 59 non-NULL hardpoints are
the 59-name WEAPONS arsenal roster".  The counts match exactly and the
sets overlap in 0 values -- two namespaces, one coincidence.

New structure doc, artefact and regenerator; other artefacts unchanged.
This commit is contained in:
Sylpheed RE agent
2026-08-27 12:39:50 +00:00
parent 9abed411ce
commit 9281aa7456
5 changed files with 697 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Census the SUB-RECORDS inside the 114 unit tables, and check the counts.
A unit `.tbl` is one IDXD pak entry. Besides `Generic`/`Maneuver`/`Effect` it
carries a `StructureCount` record and a variable number of destructible-part
records: `Turret_NNN`, `Bridge_NNN`, `Hatch_NNN`, `ShieldGenerator_NNN`,
`Thruster_NNN`, `Versatile_NNN`. Regenerates docs/re/data/unit-substructures.txt.
"""
import sys, os, glob, re, collections
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import unitgroup as U
# pak_entries lives in the scratch decoder; fall back to a local copy of the recipe
import struct, zlib
def pak_entries(path):
idx = open(path, 'rb').read()
n = struct.unpack_from('>I', idx, 4)[0]
parts = sorted(glob.glob(re.sub(r'\.pak$', '', path) + '.p[0-9][0-9]'))
data = b''.join(open(p, 'rb').read() for p in parts)
out = []
for i in range(n):
h, off, csz = struct.unpack_from('>III', idx, 16 + i * 12)
s = data[off:off + csz]
if s[:2] == b'Z1':
try: s = zlib.decompress(s[10:])
except Exception: continue
out.append((h, s))
return out
KIND = {'Turret': 'TurretCount', 'Bridge': 'BridgeCount', 'Hatch': 'HatchCount',
'ShieldGenerator': 'ShieldGeneratorCount', 'Thruster': 'ThrusterCount',
'Versatile': 'VersatileCount'}
def blank(n):
return not n.get('Name') and not n.get('NomalModel') and not n.get('Frame')
def units_of(pak):
out = []
for h, b in pak_entries(pak):
if b[:4] != b'IDXD': continue
try: recs = U.parse(b)
except Exception: continue
g = [r for r in recs if r['squadron'] == 'Generic']
if g and 'HP' in U.named(g[0]):
out.append((recs, U.named(g[0])))
return out
def main():
pak = glob.glob('/work/sylph_extract/**/GP_MAIN_GAME_E.pak', recursive=True)[0]
units = units_of(pak)
print("# Sub-records inside the %d unit tables of %s" % (len(units), os.path.basename(pak)))
print("# Regenerate: python3 tools/re-capture/unit_substructures.py")
print("# See docs/re/structures/unit-substructure-records.md")
norm = lambda n: re.sub(r'_\d{3}$', '_NNN', n)
fam = collections.Counter(); per = collections.Counter()
core = collections.defaultdict(collections.Counter)
for recs, gn in units:
seen = collections.Counter()
for r in recs:
n = norm(r['squadron']); fam[n] += 1; seen[n] += 1
for _, fn, _ in r['fields']:
if fn and not re.search(r'_\d{3}$', fn): core[n][fn] += 1
for n, c in seen.items(): per[n] = max(per[n], c)
print("\n## families (records, max per unit, distinct non-indexed field names)")
for n, c in fam.most_common():
print(" %-22s %5d max/unit %3d fields %3d" % (n, c, per[n], len(core[n])))
for n, _ in fam.most_common():
if n in ('Generic', 'Maneuver'): continue
print("\n## %s -- non-indexed fields (records carrying it, of %d)" % (n, fam[n]))
for k, v in sorted(core[n].items(), key=lambda x: (-x[1], x[0])):
print(" %-32s %5d" % (k, v))
res = collections.Counter(); exc = collections.defaultdict(list)
for recs, gn in units:
sc = U.named([r for r in recs if r['squadron'] == 'StructureCount'][0])
for k, f in KIND.items():
subs = [r for r in recs if re.fullmatch(k + r'_\d{3}', r['squadron'])]
w = sc.get(f, '').strip()
if not w.isdigit(): res['no field']+= 1; continue
d = int(w)
if d == len(subs): res['equal']+= 1
elif d == 0 and len(subs) == 1 and blank(U.named(subs[0])):
res['0 declared, 1 blank placeholder'] += 1
else:
res['DIFFERS'] += 1
exc[(k, d, len(subs))].append(gn.get('ID'))
print("\n## CONTROL StructureCount.<Kind>Count == #<Kind>_NNN records")
print("## (a 0 count still emits one blank slot)")
print(" %d comparisons (%d units x %d kinds)" % (len(units) * len(KIND), len(units), len(KIND)))
for k in ('equal', '0 declared, 1 blank placeholder', 'DIFFERS', 'no field'):
print(" %-34s %4d" % (k, res[k]))
print(" exceptions:")
for kk in sorted(exc, key=lambda x: -len(exc[x])):
print(" kind=%-8s declared=%-3d records=%-3d x%d" % (kk[0], kk[1], kk[2], len(exc[kk])))
for i in sorted(exc[kk]): print(" %s" % i)
wids = collections.Counter(); wnames = set()
for h, b in pak_entries(pak):
if b[:4] != b'IDXD': continue
try: recs = U.parse(b)
except Exception: continue
for r in recs:
if r['squadron'] == 'Weapon':
i = U.named(r).get('ID')
if i: wnames.add(i)
for recs, gn in units:
for r in recs:
if re.fullmatch(r'Turret_\d{3}', r['squadron']):
wids[U.named(r).get('WeaponID', '')] += 1
hit = sum(v for k, v in wids.items() if k in wnames)
print("\n## CONTROL Turret_NNN.WeaponID resolves into the Weapon datasheet")
print(" Weapon.ID values: %d" % len(wnames))
print(" turrets: %d, distinct WeaponID: %d" % (sum(wids.values()), len(wids)))
print(" resolve: %d do NOT resolve: %d" % (hit, sum(wids.values()) - hit))
print(" weapons never mounted on a turret: %d" % len(wnames - set(wids)))
main()