diff --git a/CHANGELOG.md b/CHANGELOG.md index 95205363..dff0237e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,16 @@ Since the first public release: modern renderers left the processor at full precision, and the collision tests came out differently. They now set it as the original did. (issue #43) +- **The PlayStation 2 release's extra levels**, for a build from source + with your own PS2 disc image (`make modern PS2=1`; no public archive + yet). Young Cate's three "Nine years ago" flashbacks, as a thief in + London and Scotland, play inside missions 3, 5 and 13 as on the PS2, + with the lock pick and safe cracker; "A Man of Influence" gets the + Baron's secret training rooms under his courtyard. Interface options + can also put young Cate on the main menu, or any of the menu's other + Cates. The levels draw with the PS2's own textures. With the Thief mod + installed as well, the mod's versions of the same levels take their + place. ([`docs/MODERN.md`](docs/MODERN.md), P8) - **Sound effects at retail loudness.** OpenMiles played most sounds 1.5-3 dB louder than Miles 6.0i (its volume curve was approximated, and it did not round as Miles does); its volume, master volume and pan now diff --git a/Makefile b/Makefile index 8399c7ac..bad76d3b 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ SRC_MOUNT := $(if $(NOLF_SOURCE),-v $(realpath $(NOLF_SOURCE)):/nolfsource:ro -e DOCKER_RUN = docker run --rm --init --name $(CONTAINER) \ -u $(shell id -u):$(shell id -g) -e HOME=/tmp/home $(if $(JOBS),-e NOLFSRC_JOBS=$(JOBS)) \ $(if $(NOLF_ARCHIVES),-e NOLF_ARCHIVES="$(NOLF_ARCHIVES)") \ + $(if $(PS2),-e NOLFSRC_PS2=1) \ -v $(CURDIR):/repo -w /repo $(SRC_MOUNT) $(IMAGE) TOOLCHAIN_FILES = toolchains/msvc-6.00-9782/VC98/Bin/CL.EXE \ @@ -56,6 +57,9 @@ help: @echo "make debug the game with symbols and NOLFSRC_DEBUG logging, in out/nolf-legacy-debug/" @echo "make modern the game with NOLFSRC_MODERN (the retail bugs fixed), in out/nolf-modern-release/" @echo "make modern-debug both, in out/nolf-modern-debug/" + @echo " (with PS2=1, the modern ones also get the PS2 release's levels: the three" + @echo " \"Nine years ago\" flashbacks and the Baron's training rooms, from the PS2" + @echo " disc in rez/isos/ps2/; no public archive)" @echo " (none of these is ever scored; build/variant.sh)" @echo "make test scripted playtest of out/nolf-legacy-release/ ($(TEST_LEVELS)), under Xvfb" @echo "make test-modern the same of out/nolf-modern-release/" diff --git a/build/all.sh b/build/all.sh index 6c991f8a..3c19d7a0 100755 --- a/build/all.sh +++ b/build/all.sh @@ -137,6 +137,12 @@ python3 build/retailextract.py lithtechres "$RETAIL" src/engine/lithtech/res step "2c. Game of the Year text: the retail CRES.dll in $RETAIL -> src/game/ClientRes/goty/" python3 build/retailextract.py goty "$RETAIL" src/game/ClientRes/goty +step "2d. the PS2 levels' text (modern build, make modern PS2=1): the PS2 disc in rez/isos/ps2/ -> src/game/ClientRes/goty/ps2.rc" +case "${NOLFSRC_CFLAGS:-}" in + *NOLFSRC_MODERN*) bash build/ps2/ps2build.sh strings src/game/ClientRes/goty/ps2.rc ;; + *) rm -f src/game/ClientRes/goty/ps2.rc ;; +esac + step "3. compile: libraries, game DLLs, engine (build/compile.sh: build/build.mk, ${NOLFSRC_JOBS:-$(nproc)} jobs)" GAME_CONFIG=final bash build/compile.sh all for f in src/engine/ltmsg/Release/ltmsg.dll src/engine/sres/Release/sres.dll \ diff --git a/build/build.mk b/build/build.mk index 267708ec..85ce18c3 100644 --- a/build/build.mk +++ b/build/build.mk @@ -309,7 +309,9 @@ $(eval $(call compile,$(CRES)/Release/main.obj,$(CRES),main.cpp,$(ENV_GAME),$(CL CRES_RC := $(CRES)/goty/cres_goty.rc # (a printf format: \\ is one backslash) hash := \# -CRES_RC_FMT := $(hash)include "..\\ClientRes.rc"\r\n$(hash)include "goty.rc"\r\n$(foreach r,$(if $(MODERN),$(notdir $(wildcard src/modern/cres/*.rc))),$(hash)include "..\\..\\..\\modern\\cres\\$(r)"\r\n) +# The modern build adds the PS2 levels' text (goty/ps2.rc) when build/all.sh +# made it from the PS2 disc (MODERN.md, P8). +CRES_RC_FMT := $(hash)include "..\\ClientRes.rc"\r\n$(hash)include "goty.rc"\r\n$(foreach r,$(if $(MODERN),$(notdir $(wildcard src/modern/cres/*.rc))),$(hash)include "..\\..\\..\\modern\\cres\\$(r)"\r\n)$(if $(MODERN),$(if $(wildcard $(CRES)/goty/ps2.rc),$(hash)include "ps2.rc"\r\n)) $(CRES_RC): FORCE @test -f $(CRES)/goty/goty.rc -a -f $(CRES)/goty/credits.txt || \ { echo "error: $(CRES)/goty/ is missing -- build/all.sh writes it" >&2; exit 1; } diff --git a/build/package.sh b/build/package.sh index d445cb36..b17353a5 100755 --- a/build/package.sh +++ b/build/package.sh @@ -66,6 +66,13 @@ if [[ $ARCHIVES == *" private "* ]]; then echo " $PKG: $(du -h "$PKG" | cut -f1) (unpacks to $NAME/)" fi [[ $ARCHIVES == *" public "* ]] || exit 0 +# Nor of a game directory with the PS2 levels (make modern PS2=1): they are +# made from the PS2 disc, which the install scripts do not read. +if [ -f "$OUT/custom/PS2-DATA.txt" ]; then + echo " $PARENT/$NAME-public.tar.gz: skipped (the game directory has the PS2 levels, custom/PS2-DATA.txt)" + rm -f "$PARENT/$NAME-public.tar.gz" + exit 0 +fi # The public archive: the scripts beside the rest, the disc files left out. PUB="$PARENT/$NAME-public.tar.gz" diff --git a/build/ps2/abcmodel.py b/build/ps2/abcmodel.py new file mode 100644 index 00000000..d74324ba --- /dev/null +++ b/build/ps2/abcmodel.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""NOLF PC model file (.abc, versions 11/12): an in-memory model, a reader and a +writer. The layout is in spec-models.md. The writer is byte-exact against the +shipped PC files (abcmodel.py check FILE... reads, rewrites and compares). + + abcmodel.py check FILE... round-trip test + abcmodel.py dump FILE summary +""" +import struct, sys + + +class Node: + def __init__(self, name='', index=0, flags=0, matrix=None, nchildren=0): + self.name, self.index, self.flags = name, index, flags + # 4x4 row-major, rows are (r0..r3); translation in column 3 + self.matrix = matrix or [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + self.nchildren = nchildren + + +class Vertex: + def __init__(self, pos, normal, weights, sublod=0xCDCD): + self.pos, self.normal = pos, normal + self.weights = weights # [(node, (x,y,z) node-space pos, bias)] + self.sublod = sublod + + +class LOD: + def __init__(self): + self.faces = [] # [((u,v,vi), (u,v,vi), (u,v,vi))] + self.verts = [] + + +class Piece: + def __init__(self, name='', material=0, spec_power=0.0, spec_scale=1.0, + lod_weight=1.0, unknown=0): + self.name, self.material = name, material + self.spec_power, self.spec_scale, self.lod_weight = spec_power, spec_scale, lod_weight + self.unknown = unknown + self.lods = [] + + +class ChildModel: + def __init__(self, name='', build=0, xforms=None): + self.name, self.build = name, build + self.xforms = xforms or [] # per node: ((x,y,z), (qx,qy,qz,qw)) + + +class Anim: + def __init__(self, name='base', dims=(0, 0, 0)): + self.name, self.dims = name, dims + self.val = -1 # v12: always -1 in what we write + self.interp = 200 + self.keys = [] # [(time ms, command string)] + self.tracks = [] # per node: [((x,y,z), (qx,qy,qz,qw))] per key + + +class Socket: + def __init__(self, node, name, rot, pos): + self.node, self.name, self.rot, self.pos = node, name, rot, pos + + +class Model: + def __init__(self): + self.version = 12 + self.command = '' + self.radius = 0.0 + self.lod_dists = [] + self.nodes, self.pieces, self.weight_sets = [], [], [] + self.children = [ChildModel()] # [0] is the model itself (empty name) + self.anims, self.sockets = [], [] + self.bindings = [] # [(name, dims, origin)] for own anims + self.child_bindings = [] # one list per children[1:] + self.lod_count = 1 + # header counts as read (None = compute when writing) + self.hdr = None + + # ---- counts the header carries + def strings(self): + """The distinct names the engine copies into the model's string pool: + the command string, node, animation, keyframe-command and child-model + names, de-duplicated case-sensitively, empty ones not counted (piece, + socket and weight-set names go into fixed buffers). This reproduces the + header's two string counts of every v12 .abc NOLF ships.""" + seen, s = [], set() + for n in ([self.command] + [x.name for x in self.nodes] + + [a.name for a in self.anims] + + [k[1] for a in self.anims for k in a.keys] + + [c.name for c in self.children]): + if n and n not in s: + s.add(n) + seen.append(n) + return seen + + def counts(self): + st = self.strings() + nfaces = sum(len(l.faces) for p in self.pieces for l in p.lods) + nverts = sum(len(l.verts) for p in self.pieces for l in p.lods) + nweights = sum(len(v.weights) for p in self.pieces for l in p.lods for v in l.verts) + return [sum(len(a.keys) for a in self.anims), len(self.anims), len(self.nodes), + len(self.pieces), len(self.children), nfaces, nverts, nweights, + self.lod_count, len(self.sockets), len(self.weight_sets), + len(st), sum(len(x) for x in st)] + + +# ------------------------------------------------------------------ reading + +class R: + def __init__(self, d, o=0): + self.d, self.o = d, o + + def u(self, fmt): + v = struct.unpack_from('<' + fmt, self.d, self.o) + self.o += struct.calcsize('<' + fmt) + return v if len(v) > 1 else v[0] + + def s(self): + n = self.u('H') + v = self.d[self.o:self.o + n].decode('latin-1') + self.o += n + return v + + +def read_abc(data): + r = R(data) + m = Model() + secs = {} + while True: + name = r.s() + nxt = r.u('I') + secs[name] = r.o + if name == 'Header': + m.version = r.u('I') + c = list(r.u('11I')) + if m.version >= 13: + r.u('I') + c += list(r.u('2I')) + m.hdr = c + m.lod_count = c[8] + m.command = r.s() + m.radius = r.u('f') + nd = r.u('I') + r.o += 60 + m.lod_dists = list(r.u('%df' % nd)) if nd > 1 else ([r.u('f')] if nd else []) + elif name == 'Pieces': + r.u('I') # weight count + for _ in range(r.u('I')): + p = Piece() + p.material, p.spec_power, p.spec_scale = r.u('H'), r.u('f'), r.u('f') + if m.version > 9: + p.lod_weight = r.u('f') + p.unknown = r.u('H') + p.name = r.s() + for _ in range(m.lod_count): + l = LOD() + for _ in range(r.u('I')): + fv = [] + for _ in range(3): + u, v = r.u('2f') + fv.append((u, v, r.u('H'))) + l.faces.append(tuple(fv)) + for _ in range(r.u('I')): + nw, sub = r.u('2H') + ws = [] + for _ in range(nw): + ni = r.u('I') + ws.append((ni, r.u('3f'), r.u('f'))) + l.verts.append(Vertex(r.u('3f'), r.u('3f'), ws, sub)) + p.lods.append(l) + m.pieces.append(p) + elif name == 'Nodes': + for _ in range(c[2]): + n = Node(r.s(), r.u('H'), r.u('B')) + f = r.u('16f') + n.matrix = [list(f[0:4]), list(f[4:8]), list(f[8:12]), list(f[12:16])] + n.nchildren = r.u('I') + m.nodes.append(n) + for _ in range(r.u('I')): + wn = r.s() + k = r.u('I') + m.weight_sets.append((wn, list(r.u('%df' % k)) if k > 1 else ([r.u('f')] if k else []))) + elif name == 'ChildModels': + m.children = [] + for _ in range(r.u('H')): + cm = ChildModel(r.s(), r.u('I')) + for _ in range(c[2]): + cm.xforms.append((r.u('3f'), r.u('4f'))) + m.children.append(cm) + elif name == 'Animation': + for _ in range(r.u('I')): + a = Anim() + a.dims = r.u('3f') + a.name = r.s() + a.val = r.u('i') + if m.version >= 12: + a.interp = r.u('I') + nk = r.u('I') + else: + nk = r.u('I') if a.val == -1 else a.val + for _ in range(nk): + t = r.u('I') + a.keys.append((t, r.s())) + for _ in range(c[2]): + a.tracks.append([(r.u('3f'), r.u('4f')) for _ in range(nk)]) + m.anims.append(a) + elif name == 'Sockets': + for _ in range(r.u('I')): + ni = r.u('I') + sn = r.s() + m.sockets.append(Socket(ni, sn, r.u('4f'), r.u('3f'))) + elif name == 'AnimBindings': + def binds(): + return [(r.s(), r.u('3f'), r.u('3f')) for _ in range(r.u('I'))] + m.bindings = binds() + m.child_bindings = [binds() for _ in m.children[1:]] + if nxt == 0xFFFFFFFF: + break + r.o = nxt + m.sections = secs + return m + + +# ------------------------------------------------------------------ writing + +class W: + def __init__(self): + self.b = bytearray() + + def p(self, fmt, *v): + self.b += struct.pack('<' + fmt, *v) + + def s(self, txt): + e = txt.encode('latin-1') + self.p('H', len(e)) + self.b += e + + def section(self, name): + if hasattr(self, 'last'): + struct.pack_into(' ++FinalScale = <0.0134, 0.0134, 0.0134> ++UseColors = 0 ++InitialColor = <128.0, 128.0, 128.0> ++FinalColor = <128.0, 128.0, 128.0> ++InitialAlpha = 1.0 ++FinalAlpha = 1.0 ++DirOffset = 1 ++DirROffset = 0 ++DirUOffset = 0.20 ++Velocity = <0.000000, 0.000000, 0.000000> ++LifeTime = 1000000.00000 ++DelayTime = 0.0 ++Loop = 1 ++AlignToSurface = 0 ++NoZ = 0 ++ReallyClose = 1 ++Additive = 0 ++Multiply = 0 ++Chromakey = 0 ++Rotate = 0 ++FaceCamera = 0 ++RotationAxis = 1 ++MinRotVel = 1.0 ++MaxRotVel = 1.0 ++ ++/* ++** ScaleFX252 - Popup HandBag model (PS2 ScaleFX263) ++*/ ++[ScaleFX252] ++ ++Name = "HandBagPopup" ++Type = 0 ++File = "Interface\Models\Purses_01.abc" ++Skin = "Props\Skins\Purses_01.dtx" ++InitialScale = <0.0111, 0.0111, 0.0111> ++FinalScale = <0.0111, 0.0111, 0.0111> ++UseColors = 0 ++InitialColor = <128.0, 128.0, 128.0> ++FinalColor = <128.0, 128.0, 128.0> ++InitialAlpha = 1.0 ++FinalAlpha = 1.0 ++DirOffset = 1 ++DirROffset = 0 ++DirUOffset = 0.20 ++Velocity = <0.000000, 0.000000, 0.000000> ++LifeTime = 1000000.00000 ++DelayTime = 0.0 ++Loop = 1 ++AlignToSurface = 0 ++NoZ = 0 ++ReallyClose = 1 ++Additive = 0 ++Multiply = 0 ++Chromakey = 0 ++Rotate = 0 ++FaceCamera = 0 ++RotationAxis = 1 ++MinRotVel = 1.0 ++MaxRotVel = 1.0 ++ ++/* ++** ScaleFX254 - Popup Paper Money model (PS2 ScaleFX264) ++*/ ++[ScaleFX254] ++ ++Name = "PaperMoneyPopup" ++Type = 0 ++File = "Interface\Models\Change_01.abc" ++Skin = "Interface\Skins\Change_01.dtx" ++InitialScale = <0.0371, 0.0371, 0.0371> ++FinalScale = <0.0371, 0.0371, 0.0371> ++UseColors = 0 ++InitialColor = <128.0, 128.0, 128.0> ++FinalColor = <128.0, 128.0, 128.0> ++InitialAlpha = 1.0 ++FinalAlpha = 1.0 ++DirOffset = 1 ++DirROffset = 0 ++DirUOffset = 0.20 ++Velocity = <0.000000, 0.000000, 0.000000> ++LifeTime = 1000000.00000 ++DelayTime = 0.0 ++Loop = 1 ++AlignToSurface = 0 ++NoZ = 0 ++ReallyClose = 1 ++Additive = 0 ++Multiply = 0 ++Chromakey = 0 ++Rotate = 0 ++FaceCamera = 0 ++RotationAxis = 1 ++MinRotVel = 1.0 ++MaxRotVel = 1.0 + + /************************************************************************ + ** diff --git a/build/ps2/data/ATTRIBUTES/INTELITEMS.txt.patch b/build/ps2/data/ATTRIBUTES/INTELITEMS.txt.patch new file mode 100644 index 00000000..0da770a9 --- /dev/null +++ b/build/ps2/data/ATTRIBUTES/INTELITEMS.txt.patch @@ -0,0 +1,51 @@ +--- a/ATTRIBUTES/INTELITEMS.txt ++++ b/ATTRIBUTES/INTELITEMS.txt +@@ -356,3 +356,48 @@ + ScaleName0 = "PopupBackground" + ScaleName1 = "DimsOnlyPopup" + ++/* ++** Intel19 - HandBag (PS2 "Nine years ago" levels) ++*/ ++ ++[Intel19] ++ ++Name = "HandBag" ++Filename = "Props\Models\Purses_01.abc" ++Skin = "Props\Skins\Purses_01.dtx" ++DefaultTextId = 8019 ++Chrome = 0 ++Chromakey = 0 ++ScaleName0 = "PopupBackground" ++ScaleName1 = "HandBagPopup" ++ ++/* ++** Intel20 - Money Bag (PS2 "Nine years ago" levels) ++*/ ++ ++[Intel20] ++ ++Name = "MoneyBag" ++Filename = "Props\Models\MoneyBag.abc" ++Skin = "Props\Skins\MoneyBag.dtx" ++DefaultTextId = 8020 ++Chrome = 0 ++Chromakey = 0 ++ScaleName0 = "PopupBackground" ++ScaleName1 = "MoneyBagPopup" ++ ++/* ++** Intel21 - Paper Money (PS2 "Nine years ago" levels) ++*/ ++ ++[Intel21] ++ ++Name = "Paper Money" ++Filename = "Props\Models\Change_01.abc" ++Skin = "Props\Skins\Change_01b.dtx" ++DefaultTextId = 8021 ++Chrome = 0 ++Chromakey = 0 ++ScaleName0 = "PopupBackground" ++ScaleName1 = "PaperMoneyPopup" ++ diff --git a/build/ps2/data/ATTRIBUTES/MISSIONS.txt.patch b/build/ps2/data/ATTRIBUTES/MISSIONS.txt.patch new file mode 100644 index 00000000..a7fae2f7 --- /dev/null +++ b/build/ps2/data/ATTRIBUTES/MISSIONS.txt.patch @@ -0,0 +1,83 @@ +--- a/ATTRIBUTES/MISSIONS.txt ++++ b/ATTRIBUTES/MISSIONS.txt +@@ -678,7 +678,8 @@ + Rank5 = "P 1" + Rank6 = "D 1" + Level0 = "Worlds\m03s01" +-Level1 = "Worlds\m03s02" ++Level1 = "Worlds\thiefmission1" ++Level2 = "Worlds\m03s02" + + [Mission5] + // Care to Explain? +@@ -824,11 +825,11 @@ + DefaultGadgets = "Coin,Barrette" + AllowedGadgets = "" + RequiredGadgets = "Sunglasses,Lighter" +-OneTimeGadgets = "" ++OneTimeGadgets = "Thief Lock Pick,Thief Safe Cracker" + DeniedGadgets = "Space Code Breaker" + AllowedAmmo = "BarretteLockpick,BarrettePoison," + RequiredAmmo = "Camera,Lighter,Welder" +-OneTimeAmmo = "" ++OneTimeAmmo = "Safe Cra Gadget" + DeniedAmmo = "" + AllowedMods = "Delisle Scope" + DefaultMods = "P38 Silencer,Delisle Scope" +@@ -854,7 +855,8 @@ + Level1 = "Worlds\m05s02" + Level2 = "Worlds\m05s03" + Level3 = "Worlds\m05s04" +-Level4 = "Worlds\m05s05" ++Level4 = "Worlds\thiefmission2" ++Level5 = "Worlds\m05s05" + + [Mission9] + // H.A.R.M.'s Dire Promise +@@ -995,7 +997,7 @@ + DefaultWeapons = "P38,Perfume Stun Gas,Delisle" + AllowedWeapons = "Revolver,Perfume Stun Gas,Sterling,Lipstick Timed Bomb,Delisle,Luger" + RequiredWeapons = "" +-OneTimeWeapons = "" ++OneTimeWeapons = "Crossbow,Rocket Launcher" + DeniedWeapons = "Space Lasergun,Space Fists,Space Atomic Lasergun" + DefaultGadgets = "Barrette,Poodle" + AllowedGadgets = "Poodle" +@@ -1028,7 +1030,8 @@ + Rank6 = "Ar 1" + Level0 = "Worlds\m07s01" + Level1 = "Worlds\m07s02" +-Level2 = "Worlds\m07s03" ++Level2 = "Worlds\m07s03a" ++Level3 = "Worlds\m07s03b" + + [Mission13] + // Further Investigation Required +@@ -1443,11 +1446,11 @@ + DefaultGadgets = "Coin,Lighter,Barrette,Decay Powder,Zipcord" + AllowedGadgets = "" + RequiredGadgets = "" +-OneTimeGadgets = "" ++OneTimeGadgets = "Thief Lock Pick,Thief Safe Cracker" + DeniedGadgets = "Space Code Breaker" + AllowedAmmo = "Lighter,Welder" + RequiredAmmo = "" +-OneTimeAmmo = "" ++OneTimeAmmo = "Safe Cra Gadget" + DeniedAmmo = "" + AllowedMods = "AK47 Scope,Dragunov Nightvision Scope,Walther SMG Silencer" + DefaultMods = "P38 Silencer" +@@ -1470,9 +1473,10 @@ + Rank5 = "S 1" + Rank6 = "D 1" + Level0 = "Worlds\m13s01" +-Level1 = "Worlds\m13s02" +-Level2 = "Worlds\m13s03" +-Level3 = "Worlds\m13s04" ++Level1 = "Worlds\thiefmission3" ++Level2 = "Worlds\m13s02" ++Level3 = "Worlds\m13s03" ++Level4 = "Worlds\m13s04" + + [Mission23] + // Escape - Mission 14 diff --git a/build/ps2/data/ATTRIBUTES/MODELBUTES.txt.patch b/build/ps2/data/ATTRIBUTES/MODELBUTES.txt.patch new file mode 100644 index 00000000..9cd03c0d --- /dev/null +++ b/build/ps2/data/ATTRIBUTES/MODELBUTES.txt.patch @@ -0,0 +1,13 @@ +--- a/ATTRIBUTES/MODELBUTES.txt ++++ b/ATTRIBUTES/MODELBUTES.txt +@@ -653,6 +653,10 @@ + [Style16] + + Name = "Style8" ++ ++[Style17] ++ ++Name = "Thief" + + /* + ** Skeleton [node group] Attributes diff --git a/build/ps2/data/ATTRIBUTES/WEAPONS.txt.patch b/build/ps2/data/ATTRIBUTES/WEAPONS.txt.patch new file mode 100644 index 00000000..bfad1548 --- /dev/null +++ b/build/ps2/data/ATTRIBUTES/WEAPONS.txt.patch @@ -0,0 +1,195 @@ +--- a/ATTRIBUTES/WEAPONS.txt ++++ b/ATTRIBUTES/WEAPONS.txt +@@ -102,19 +102,21 @@ + Weapon33 = "Space Fists" + Weapon34 = "Space Atomic Lasergun" + Weapon35 = "Space Code Breaker" +-LastPlayerWeapon = 35 ++Weapon36 = "Thief Lock Pick" ++Weapon37 = "Thief Safe Cracker" ++LastPlayerWeapon = 37 + + // Non player weapons (must come after the player weapons)... + // +-Weapon36 = "Heli_Machinegun" +-Weapon37 = "Helicopter RL" +-Weapon38 = "Scot Punch" +-Weapon39 = "Scot Slam" +-Weapon40 = "German Sword" +-Weapon41 = "Shark Bite" +-Weapon42 = "Hand Grenade" +-Weapon43 = "Dynamite" +-Weapon44 = "Super Shark Bite" ++Weapon38 = "Heli_Machinegun" ++Weapon39 = "Helicopter RL" ++Weapon40 = "Scot Punch" ++Weapon41 = "Scot Slam" ++Weapon42 = "German Sword" ++Weapon43 = "Shark Bite" ++Weapon44 = "Hand Grenade" ++Weapon45 = "Dynamite" ++Weapon46 = "Super Shark Bite" + + /************************************************************************ + ** +@@ -2111,6 +2113,34 @@ + FireFXName = "" + TracerFXName = "" + WeaponAniOverride = "" ++ ++/* ++** Ammo54 - Thief Safe Cracker gadget ammo (PS2 Ammo55) ++*/ ++ ++[Ammo54] ++ ++Name = "Safe Cra Gadget" ++NameId = 31714 ++DescId = 31715 ++Type = 3 ++Priority = 54 ++Icon = "Interface\Photos\Ammo\Func_Cracker.pcx" ++SmallIcon = "Interface\Photos\Ammo\Func_Cracker_SM.pcx" ++MaxAmount = 1 ++SelectionAmount = 1 ++SpawnedAmount = 1 ++InstDamage = 0 ++InstDamageType = 22 ++AreaDamage = 0 ++AreaDamageType = 0 ++AreaDamageRadius = 0 ++ProgDamage = 0.000000 ++ProgDamageType = 0 ++ProgDamageDuration = 0.000000 ++ProgDamageRadius = 0.000000 ++ProgDamageLifetime = 0.000000 ++FireRecoilMult = 1.000000 + + + /************************************************************************ +@@ -5208,6 +5238,125 @@ + HideWhenEmpty = 0 + + ++/* ++** Thief Safe Cracker (PS2 "Nine years ago" levels; PS2 Weapon45) ++*/ ++ ++[Weapon45] ++ ++Name = "Thief Safe Cracker" ++NameId = 31710 ++DescriptionId = 31711 ++Gadget = 1 ++AniType = 2 ++Icon = "Interface\Photos\Weapons\PS2SC.pcx" ++SmallIcon = "Interface\Photos\Weapons\PS2SC_SM.pcx" ++Photo = "Interface\Photos\Weapons\PS2SC.pcx" ++Pos = <0.100000, -0.800000, 3.000000> ++MuzzlePos = <2.100000, 1.700000, 17.400000> ++MinPerturb = 0 ++MaxPerturb = 0 ++Range = 100 ++Recoil = <0.000000, 0.000000, 0.000000> ++VectorsPerRound = 1 ++PVModel = "Guns\Models_PV\PS2SafeCracker_pv.abc" ++PVSkin = "Guns\Skins_PV\PS2SafeCracker_pv_leds.dtx" ++PVMuzzleFXName = "" ++HHModel = "Guns\Models_HH\PS2SafeCracker_hh.abc" ++HHSkin = "Guns\Skins_PV\PS2SafeCracker_pv.dtx" ++HHModelScale = <1.000000, 1.000000, 1.000000> ++HHBreachOffset = -10.000000 ++HHMuzzleFXName = "" ++InterfaceModel = "Guns\Models_HH\PS2SafeCracker_hh.abc" ++InterfaceSkin = "Guns\Skins_PV\PS2SafeCracker_pv.dtx" ++InterfaceModelScale = 1.000000 ++InterfaceOffset = <0.000000, 0.000000, 0.000000> ++SilencedFireSnd = "" ++AltFireSnd = "" ++FireSnd = "Guns\Snd\safe\fire.wav" ++DryFireSnd = "" ++ReloadSnd1 = "" ++SelectSnd = "Guns\Snd\safe\select.wav" ++DeselectSnd = "" ++FireSndRadius = 128 ++AIFireSndRadius = 64 ++EnvironmentMap = 1 ++InfiniteAmmo = 0 ++ShotsPerClip = 1 ++AmmoMultiplier = 1 ++FireRecoilPitch = 0.000000 ++FireRecoilDecay = 0.000000 ++AmmoName0 = "Safe Cra Gadget" ++AIMinBurstInterval = 0.500000 ++AIMaxBurstInterval = 1.500000 ++AIMinBurstShots = 1 ++AIMaxBurstShots = 5 ++LooksDangerous = 0 ++CanBeDefault = 0 ++CanBeMapped = 1 ++FireDelay = 810 ++HideWhenEmpty = 1 ++ ++ ++/* ++** Thief Lock Pick (PS2 "Nine years ago" levels; PS2 Weapon47) ++*/ ++ ++[Weapon46] ++ ++Name = "Thief Lock Pick" ++NameId = 31712 ++DescriptionId = 31713 ++Gadget = 1 ++AniType = 2 ++Icon = "Interface\Photos\Weapons\PS2lp.pcx" ++SmallIcon = "Interface\Photos\Weapons\PS2lp_SM.pcx" ++Photo = "Interface\Photos\Weapons\PS2lp.pcx" ++Pos = <0.100000, -0.300000, 1.750000> ++MuzzlePos = <1.000000, -1.100000, 13.800000> ++MinPerturb = 0 ++MaxPerturb = 5 ++Range = 100 ++Recoil = <0.000000, 0.000000, 0.000000> ++VectorsPerRound = 1 ++PVModel = "Guns\Models_PV\PS2lockpick_pv.abc" ++PVSkin = "Guns\Skins_PV\PS2lockpick_pv.dtx" ++PVMuzzleFXName = "" ++HHModel = "Guns\Models_HH\PS2lockpick_hh.abc" ++HHSkin = "Guns\Skins_PV\PS2lockpick_pv.dtx" ++HHModelScale = <1.000000, 1.000000, 1.000000> ++HHBreachOffset = 0.000000 ++HHMuzzleFXName = "" ++InterfaceModel = "Guns\Models_HH\barret_hh.abc" ++InterfaceSkin = "Guns\Skins_PV\PS2lockpick_pv.dtx" ++InterfaceModelScale = 3.500000 ++InterfaceOffset = <0.000000, 0.000000, 0.000000> ++SilencedFireSnd = "" ++FireSnd = "Guns\Snd\lipstick\reload1.wav" ++DryFireSnd = "" ++ReloadSnd1 = "" ++SelectSnd = "Guns\Snd\barrette\select.wav" ++DeselectSnd = "Guns\Snd\barrette\deselect.wav" ++FireSndRadius = 128 ++AIFireSndRadius = 0 ++EnvironmentMap = 1 ++InfiniteAmmo = 1 ++ShotsPerClip = 1 ++AmmoMultiplier = 1 ++FireRecoilPitch = 0.100000 ++FireRecoilDecay = 0.175000 ++AmmoName0 = "BarretteLockpick" ++AIMinBurstInterval = 0.500000 ++AIMaxBurstInterval = 1.500000 ++AIMinBurstShots = 1 ++AIMaxBurstShots = 5 ++LooksDangerous = 0 ++CanBeDefault = 0 ++CanBeMapped = 1 ++FireDelay = 800 ++HideWhenEmpty = 0 ++ ++ + /************************************************************************ + ** + ** MOD diff --git a/build/ps2/pcworld.py b/build/ps2/pcworld.py new file mode 100644 index 00000000..da2d2963 --- /dev/null +++ b/build/ps2/pcworld.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Walk a PC NOLF (LithTech 2.x, version 66) world .dat section by section. + +Follows build/ps2/spec-pc-world.md, which follows the engine +reconstruction: + header ............ commonmodel.cpp w_ReadWorldHeader + info/extents/tree . commonmodel.cpp CServerWorldFull::Load, worldtree.cpp LoadLayout + world models ...... worldbsp.cpp w_LoadWorldModel, CWorldSimple::Load, worldmodels.cpp CSubGrid::Load + object list ....... servermodel.cpp sm_LoadWorldObjects, commonmodel.cpp w_ReadWorldLights + lightmap anims .... worldmodels.cpp CWorldData::LoadObjects + +Usage: pcworld.py [-v] world.dat [world.dat ...] +Every byte is accounted for: the script reports gaps/overlaps between sections +and whether the last section ends at EOF. +""" +import struct +import sys +from collections import Counter + + +class R: + def __init__(self, data, pos=0): + self.d = data + self.p = pos + + def u8(self): + v = self.d[self.p]; self.p += 1; return v + + def u16(self): + v = struct.unpack_from('= nTex: + texidx_bad += 1 + fl = r.u32(); sflags[fl] += 1 + b.setdefault('s44', Counter())[r.u8()] += 1 # +0x44 + b.setdefault('sRGB', Counter())[(r.u8(), r.u8(), r.u8())] += 1 # ignored colour + has = r.u8() + if has == 1: + effects += 1 + r.lstr(); r.lstr() # effect class, params + b.setdefault('texFlags', Counter())[r.u16()] += 1 # texture flags (surface type) + b['surfaceFlags'] = sflags; b['surfEffects'] = effects; b['surfBadTex'] = texidx_bad + + # polies + edgeTotal = 0 + for i in range(nPolies): + r.vec() # lightmap origin (+0x34) + lw = r.u16(); lh = r.u16() # lightmap w/h (stored as bytes) + b.setdefault('lmSizes', Counter())[(lw, lh)] += 1 + ne = r.u16(); edgeTotal += ne + for _ in range(ne): + a = r.u16(); k = r.u16() + b.setdefault('lmrefs', []).append((i, a, k)) + surf = r.u32(); assert surf < nSurfaces, 'poly surface out of range' + nv = vcounts[i][0] + vcounts[i][1] + for _ in range(nv): + pt = r.u16(); assert pt < nPoints, 'poly point out of range' + r.u8(); r.u8(); r.u8() + assert edgeTotal <= nEdges, (edgeTotal, nEdges) + b['edgesUsed'] = edgeTotal + + # nodes + term = Counter() + nodes = [] + for i in range(nNodes): + p = r.u32(); assert p < nPolies, 'node poly out of range' + leaf = r.u16() # leaf index (0xffff none) + assert leaf == 0xffff or leaf < nLeafs, 'node leaf out of range' + cs = [] + for _ in range(2): + c = r.i32() + if c < 0: + assert c in (-1, -2); term[c] += 1 + else: + assert c < nNodes + cs.append(c) + nodes.append((p, leaf, cs[0], cs[1])) + b['nodes'] = nodes + b['nodesWithLeaf'] = sum(1 for n in nodes if n[1] != 0xffff) + b['nodeTerminals'] = dict(term) + + # user portals + for i in range(nPortals): + r.lstr(); r.u32(); r.u16(); r.vec(); r.vec() + + # points (position + an ignored normal) + for i in range(nPoints): + r.vec(); b.setdefault('ptNormal', Counter())[r.vec()] += 1 + + # PBlock table (CSubGrid::Load) + cx, cy, cz = r.i32(), r.i32(), r.i32() + csize = r.vec(); corg = r.vec() + ncells = cx * cy * cz + nodes6 = 0; empty = 0 + cells = [] + for i in range(ncells): + cnt = r.u16(); root = r.u16() + recs = [struct.unpack_from('<3H', r.raw(6)) for _ in range(cnt)] + nodes6 += cnt + if cnt == 0: + empty += 1 + # WSNode {u16 bspNode, u16 back, u16 front}; children index this cell's run + # or are 0xfffe (empty) / 0xffff (solid); root likewise + assert root in (0xfffe, 0xffff) or root < cnt, ('pblock root', root, cnt) + for (bn, c0, c1) in recs: + assert bn < nNodes, 'pblock node -> bsp node out of range' + for c in (c0, c1): + assert c in (0xfffe, 0xffff) or c < cnt + cells.append((root, recs)) + b['cells'] = cells + b['pblock'] = dict(dims=(cx, cy, cz), cell=csize, origin=corg, + cells=ncells, nodes=nodes6, emptyCells=empty) + + root = r.i32() + assert root in (-1, -2) or 0 <= root < nNodes + b['root'] = root + + nTerrain = r.u32() + b['nTerrain'] = nTerrain + for t in range(nTerrain): + nn = r.u32() + for _ in range(nn): + r.u32(); r.i32(); r.i32() + r.i32() + np_ = r.u32() + for _ in range(np_): + r.u32() + cx, cy, cz = r.i32(), r.i32(), r.i32(); r.vec(); r.vec() + for i in range(cx * cy * cz): + cnt = r.u16(); r.u16(); r.raw(cnt * 6) + r.raw(8) # wb_ReadSimpleHeader: 4 bytes + 1 dword + r.vec() # centre + b['size'] = r.p - start + return b + + +def parse_world(path, verbose=False, data=None): + d = data if data is not None else open(path, 'rb').read() + r = R(d) + sec = [] # (name, start, end) + out = {'file': path, 'size': len(d)} + + s = r.p + version = r.u32(); assert version == 66, version + objOfs = r.u32(); lmOfs = r.u32() + hdr_unused = [r.u32() for _ in range(8)] + sec.append(('header', s, r.p)) + out.update(version=version, objOfs=objOfs, lmOfs=lmOfs, hdrUnused=hdr_unused) + + s = r.p + n = r.u32(); info = r.raw(n).decode('latin-1') + lmgrid = r.f32(); emin = r.vec(); emax = r.vec() + sec.append(('worldinfo+lmgrid+extents', s, r.p)) + out.update(info=info, lmGrid=lmgrid, extMin=emin, extMax=emax) + + s = r.p + tmin = r.vec(); tmax = r.vec(); tUnused = r.u32(); tDepth = r.u32() + # quadtree bit stream: preorder, 1 bit per node, LSB first, bytes read lazily + cur = [0, 8] + count = [0, 0] + + def node(): + if cur[1] == 8: + cur[0] = r.u8(); cur[1] = 0 + bit = (cur[0] >> cur[1]) & 1 + cur[1] += 1 + count[0] += 1 + if bit: + count[1] += 1 + for _ in range(4): + node() + node() + sec.append(('worldtree', s, r.p)) + out.update(treeMin=tmin, treeMax=tmax, treeUnused=tUnused, treeDepthDword=tDepth, + treeNodes=count[0], treeInner=count[1]) + + s = r.p + nWorlds = r.u32() + sec.append(('nWorlds', s, r.p)) + worlds = [] + for i in range(nWorlds): + s = r.p + nxt = r.u32() + b = parse_bsp(r, verbose) + b['next'] = nxt + b['slack'] = nxt - r.p # bytes between record end and next offset + worlds.append(b) + sec.append(('world[%d] %s' % (i, b['name']), s, r.p)) + if nxt != r.p: + sec.append(('world[%d] SLACK' % i, r.p, nxt)) + r.p = nxt + out['worlds'] = worlds + + # object list + assert r.p == objOfs, ('object list not right after last world', r.p, objOfs) + s = r.p + nObj = r.u32() + classes = Counter(); ptypes = Counter(); propcount = 0 + objs = [] + for i in range(nObj): + rs = r.u16(); p0 = r.p + cls = r.lstr(); classes[cls] += 1 + nprops = r.u32() + props = {} + for j in range(nprops): + pn = r.lstr(); pt = r.u8(); pf = r.u32(); pl = r.u16() + ptypes[pt] += 1; propcount += 1 + if pt == 0: + q = r.p + sv = r.lstr() + assert r.p - q == pl, ('string prop length', pn, pl, r.p - q) + props[pn] = sv + else: + props[pn] = r.raw(pl) + assert r.p - p0 == rs, ('record size mismatch', cls, rs, r.p - p0) + objs.append((cls, props)) + sec.append(('objects', s, r.p)) + out.update(nObjects=nObj, classes=classes, propTypes=ptypes, nProps=propcount) + out['objs'] = objs + + if r.p != lmOfs: + sec.append(('GAP before lightmaps', r.p, lmOfs)) + r.p = lmOfs + s = r.p + nRefs, nFramePtrs, nData, nEntries, nAnims = [r.u32() for _ in range(5)] + anims = [] + tRefs = tFrames = tData = tEnt = 0 + for a in range(nAnims): + name = r.lstr() + shadow = r.u32(); nfr = r.u32(); npol = r.u32() + refs = [(r.u16(), r.u16()) for _ in range(npol)] + out.setdefault('animrefs', []).append(refs) + dbytes = 0 + for f in range(nfr): + for p in range(npol): + sz = r.u32(); r.raw(sz); dbytes += sz + tRefs += npol; tFrames += nfr; tEnt += nfr * npol; tData += dbytes + anims.append((name, shadow, nfr, npol, dbytes)) + assert tRefs <= nRefs and tFrames <= nFramePtrs and tData <= nData and tEnt <= nEntries + sec.append(('lightanims', s, r.p)) + out.update(lmTotals=(nRefs, nFramePtrs, nData, nEntries, nAnims), + lmUsed=(tRefs, tFrames, tData, tEnt), anims=anims) + # cross-check: every poly's light-anim back-reference {iAnim, iSlot} must point + # at the anim poly-ref {world, poly} that names that very poly + bad = 0; n = 0 + for wi, b in enumerate(worlds): + for (pi, a, k) in b.get('lmrefs', []): + n += 1 + refs = out['animrefs'][a] if a < len(out.get('animrefs', [])) else None + if not refs or k >= len(refs) or refs[k] != (wi, pi): + bad += 1 + out['lmBackrefs'] = (n, bad) + out['end'] = r.p + out['sections'] = sec + return out + + +def report(o, verbose=False): + print('=' * 72) + print('%s: %d bytes, version %d, objOfs %d, lmOfs %d, unused hdr %s' + % (o['file'], o['size'], o['version'], o['objOfs'], o['lmOfs'], o['hdrUnused'])) + print('info string: %r' % o['info'][:200]) + print('lmGrid %g extents %s .. %s' % (o['lmGrid'], o['extMin'], o['extMax'])) + print('worldtree box %s..%s dwords (%d, %d), %d nodes (%d with children)' + % (o['treeMin'], o['treeMax'], o['treeUnused'], o['treeDepthDword'], + o['treeNodes'], o['treeInner'])) + print('%d world models' % len(o['worlds'])) + tot = Counter() + for i, b in enumerate(o['worlds']): + for k in ('nPoints', 'nPlanes', 'nSurfaces', 'nPortals', 'nPolies', 'nLeafs', + 'nLeafLists', 'visBytes', 'nNodes', 'nLeafPolyRefs', 'nEdges'): + tot[k] += b[k] + tot['nTextures'] += len(b['textures']) + tot['pblockCells'] += b['pblock']['cells']; tot['pblockNodes'] += b['pblock']['nodes'] + if verbose or i == 0: + print(' [%d] %-28s flags %#04x dc %#x | pts %d planes %d surfs %d portals %d polys %d ' + 'leafs %d unk %d vis %d/%d leaflists %d/%d nodes %d leafrefs %d/%d edges %d/%d ' + 'tex %d (pool %d, pad %d) eff %d badTex %d fix %d maxv %d root %d terr %d slack %d' + % (i, b['name'], b['flags'], b['dc'], b['nPoints'], b['nPlanes'], b['nSurfaces'], + b['nPortals'], b['nPolies'], b['nLeafs'], b['nUnk'], b['visUsed'], b['visBytes'], + b['leafListsUsed'], b['nLeafLists'], b['nNodes'], b['leafRefsUsed'], + b['nLeafPolyRefs'], b['edgesUsed'], b['nEdges'], len(b['textures']), + b['texStrBytes'], b['texPad'], b['surfEffects'], b['surfBadTex'], + b['fixverts_polys'], b['maxverts'], b['root'], b['nTerrain'], b['slack'])) + print(' junk8 %s' % b['junk8']) + print(' pblock %s' % b['pblock']) + print(' nodeTerminals %s' % b['nodeTerminals']) + print(' surface flags %s' % ', '.join('%#x:%d' % kv for kv in sorted(b['surfaceFlags'].items()))) + flagc = Counter(b['flags'] for b in o['worlds']) + print(' world-model flags histogram: %s' % ', '.join('%#x:%d' % kv for kv in sorted(flagc.items()))) + print(' totals: %s' % dict(tot)) + print('objects: %d, props %d, prop types %s' % (o['nObjects'], o['nProps'], dict(o['propTypes']))) + print(' top classes: %s' % o['classes'].most_common(12)) + print('lightanims: header totals %s used %s; poly back-refs %d (%d inconsistent)' + % (o['lmTotals'], o['lmUsed'], o['lmBackrefs'][0], o['lmBackrefs'][1])) + for a in o['anims'][:5]: + print(' %s' % (a,)) + if len(o['anims']) > 5: + print(' ... %d more' % (len(o['anims']) - 5)) + print('sections:') + last = 0 + for name, s, e in o['sections']: + flag = '' if s == last else ' <-- %+d from previous end' % (s - last) + if verbose or 'world[' not in name or name.startswith('world[0]') or 'SLACK' in name: + print(' %10d %10d %10d %s%s' % (s, e, e - s, name, flag)) + last = e + print('end of parse %d, file size %d -> %s' % (o['end'], o['size'], + 'EXACT' if o['end'] == o['size'] else 'MISMATCH (%+d)' % (o['size'] - o['end']))) + + +def walk_rez(rezpath, pattern='WORLDS/'): + """Parse every world .dat inside a .rez (tools/rezextract.py), one summary line each.""" + import os + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..', '..', 'tools')) + from rezextract import Rez + rz = Rez(rezpath) + f = open(rezpath, 'rb') + for p, pos, sz in rz.walk(): + if not (p.upper().startswith(pattern.upper()) and p.upper().endswith('.DAT')): + continue + f.seek(pos); data = f.read(sz) + try: + o = parse_world(p, data=data) + print('%-50s %9d %s worlds %4d objs %5d anims %3d backrefs-bad %d' % ( + p, sz, 'EXACT' if o['end'] == sz else 'MISMATCH%+d' % (sz - o['end']), + len(o['worlds']), o['nObjects'], len(o['anims']), o['lmBackrefs'][1])) + except Exception as e: + print('%-50s %9d FAILED %r' % (p, sz, e)) + + +if __name__ == '__main__': + args = sys.argv[1:] + v = '-v' in args + if args and args[0] == '--rez': + walk_rez(args[1]) + sys.exit(0) + for a in [x for x in args if x != '-v']: + report(parse_world(a, v), v) diff --git a/build/ps2/ps2-string-map.txt b/build/ps2/ps2-string-map.txt new file mode 100644 index 00000000..9e30c704 --- /dev/null +++ b/build/ps2/ps2-string-map.txt @@ -0,0 +1,58 @@ +# PS2 string IDs used by the "Nine years ago" levels (thiefmission1-3) and +# their attribute entries -> the CRes.dll ID the modern build gives them. +# IDs only; the text is the PS2 disc's (English LTSR table 0x056BAF9E in +# LITH_PS2.REZ), which build/ps2/ps2strings.py reads at build time. +# +# ; +# +# Kept as on the PS2 (free in the PC CRes.dll and in the PC VOICE\ files; +# fixed by the converted level data and by VOICE\.wav): +# 5326, 6535-6537, 14001-14074 +# Moved to the P8 range 31700-31799 (the PC CRes.dll already has these IDs, +# or only our own attribute patches reference them): +# 239, 667-669, 4033/4034/4083/4084/4146/4346, 5047/5048/5089/5212-5216, +# 20541-20547 +# Already on the PC with the same text (nothing to add): 6505, 10176 +# (voice only), 10252, 13316. + +# loading screen and intel folder (code) +239 31700 ui ; "Nine years ago..." load-level line for thiefmission* (PC 239 = "Movement Controls") +667 31701 ui ; FolderIntel name "Moneybag %d" (PC 667 = "up"); %d -> %1!d! +668 31702 ui ; FolderIntel name "Handbag %d" (PC 668 = "down") +669 31703 ui ; FolderIntel name "Money %d" (PC 669 = "Missing file %1!s!") + +# WEAPONS.txt (our patch writes the new IDs) +4033 31710 weapon ; [Weapon45] "Thief Safe Cracker" NameId (PC 4033 empty, but Scot Slam's NameId) +4083 31711 weapon ; [Weapon45] DescriptionId +4034 31712 weapon ; [Weapon46] "Thief Lock Pick" NameId +4084 31713 weapon ; [Weapon46] DescriptionId +4146 31714 ammo ; [Ammo54] "Safe Cra Gadget" NameId +4346 31715 ammo ; [Ammo54] DescId + +# objectives (level commands: objective add/completed/remove N, transmission N) +5047 31720 objective ; thiefmission1 (PC 5047 = "") +5048 31721 objective ; thiefmission1 +5089 31722 objective ; thiefmission2 +5212 31723 objective ; thiefmission3 +5213 31724 objective ; thiefmission3 +5214 31725 objective ; thiefmission3 +5215 31726 objective ; thiefmission3 +5216 31727 objective ; thiefmission3 +5326 5326 objective ; thiefmission2 (free on the PC) + +# mission failed (level commands: MissionFailed N) +6535 6535 failed ; thiefmission1-3 (free on the PC) +6536 6536 failed ; thiefmission1-2 +6537 6537 failed ; thiefmission1-3 + +# intelligence text (Intelligence InfoId; PC 20541-20550 = "") +20541 31730 intel ; thiefmission1 handbag +20542 31731 intel ; thiefmission1 handbag +20543 31732 intel ; thiefmission1 briefcase +20544 31733 intel ; unused on the PS2 ("Two Pounds") +20545 31734 intel ; unused on the PS2 ("Three Pounds") +20546 31735 intel ; thiefmission2 money bags +20547 31736 intel ; thiefmission1-3 paper money + +# dialogue (CinematicTrigger DialogueN; subtitles + VOICE\.wav) +14001-14074 = dialogue ; thiefmission1 14001-14031, thiefmission2 14032-14045, thiefmission3 14046-14074; 14014-14017 unused (bank only) diff --git a/build/ps2/ps2build.sh b/build/ps2/ps2build.sh new file mode 100644 index 00000000..ee20dd1b --- /dev/null +++ b/build/ps2/ps2build.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# The modern build's PS2 levels (MODERN.md, P8): the PlayStation 2 release's +# three "Nine years ago" flashbacks and its "A Man of Influence" third scene, +# with the Baron's training rooms, converted from the PS2 disc. +# Opt-in: `make modern PS2=1` sets NOLFSRC_PS2, and then the PS2 disc image +# must be in rez/isos/ps2/ (either the USA or the European disc: they give +# the same files). +# +# build/ps2/ps2build.sh strings the levels' text, for CRes.dll +# (build/all.sh, step 2d) +# build/ps2/ps2build.sh install the converted levels, models, +# textures, sounds and images, and +# the attribute edits, into its +# custom/ (build/variant.sh) +# build/ps2/ps2build.sh clean take out what an install wrote +# (build/variant.sh, before the +# modern data: an install's +# attribute files are on top of +# those) +# +# Without NOLFSRC_PS2, `strings` and `install` do nothing, so a build without +# it has none of the PS2 content. Everything an +# install writes is listed in custom/PS2-DATA.txt; build/package.sh makes no +# public archive of a game directory that has it, since that data comes from +# the PS2 disc, which the public archive's install scripts cannot read. +set -e +set -o pipefail # (a failed step before a tee fails the build) +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" + +disc() { + local f + for f in "$REPO"/rez/isos/ps2/*.iso "$REPO"/rez/isos/ps2/*.ISO; do + [ -f "$f" ] && { echo "$f"; return 0; } + done + echo "error: NOLFSRC_PS2 is set, but there is no PS2 disc image (.iso) in rez/isos/ps2/" >&2 + return 1 +} + +clean() { + local custom="$1/custom" list + list="$custom/PS2-DATA.txt" + [ -f "$list" ] || return 0 + while IFS= read -r f; do + [ -n "$f" ] && rm -f "$custom/$f" + done < "$list" + rm -f "$list" + find "$custom" -mindepth 1 -depth -type d -empty -delete # (the folders they leave) + echo " removed the PS2 levels' files from $1/custom/" +} + +case "${1:-}" in +strings) + out="${2:?usage: ps2build.sh strings }" + rm -f "$out" + [ -n "${NOLFSRC_PS2:-}" ] || { echo " (no PS2 levels: make modern PS2=1 adds them)"; exit 0; } + iso="$(disc)" + python3 "$HERE/ps2strings.py" rc "$iso" "$HERE/ps2-string-map.txt" "$out" + echo " $(basename "$iso") -> $out" + ;; +install) + game="${2:?usage: ps2build.sh install }" + [ -n "${NOLFSRC_PS2:-}" ] || exit 0 + iso="$(disc)" + python3 "$HERE/ps2install.py" --ps2 "$iso" --game "$game" + python3 "$HERE/ps2data.py" "$game" | tee /dev/stderr | + sed -n 's|^ *custom/\([^ ]*\) <- .*|\1|p' >> "$game/custom/PS2-DATA.txt" + ;; +clean) + clean "${2:?usage: ps2build.sh clean }" + ;; +*) + sed -n '2,24p' "$0" >&2 + exit 2 + ;; +esac diff --git a/build/ps2/ps2data.py b/build/ps2/ps2data.py new file mode 100644 index 00000000..ff5029f4 --- /dev/null +++ b/build/ps2/ps2data.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""The PS2 "Nine years ago" levels' attribute edits (MODERN.md, P8). + + ps2data.py + +The recipes in build/ps2/data/ (unified diffs, as src/modern/data/'s) only +make sense with the converted PS2 content installed (build/ps2/ps2install.py), +so they are applied here rather than by tools/moddata.py: each one on top of +the file as the modern build leaves it -- the retail file the game loads, +with src/modern/data/'s own recipe for it, if any, applied first -- and +written into the game directory's custom/. Running it again gives the same +result. A `make modern` rewrites those files without the PS2 edits; run this +again afterwards. +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, os.path.join(REPO, "tools")) +import moddata # noqa: E402 + +DATA = os.path.join(HERE, "data") +MODERN = os.path.join(REPO, "src", "modern", "data") + + +def rez_stack(gamedir): + """moddata.rez_stack, but matching the archive names whatever their + case: the retail launcher writes nolfcmds.txt with NOLF.rez, NOLF2.rez, + which on Linux are not the files nolf.rez, nolf2.rez.""" + names = {n.lower(): n for n in os.listdir(gamedir)} + cmds = open(os.path.join(gamedir, "nolfcmds.txt"), errors="replace").read() + stack = [] + for name in moddata.re.findall(r"-rez\s+(\S+)", cmds): + real = names.get(name.lower()) + if real and real.lower().endswith(".rez") and os.path.isfile(os.path.join(gamedir, real)): + stack.append(os.path.join(gamedir, real)) + return stack + + +moddata.rez_stack = rez_stack + + +def patches(): + for root, _, fs in os.walk(DATA): + for f in sorted(fs): + if f.endswith(".patch"): + p = os.path.join(root, f) + yield os.path.relpath(p, DATA)[:-len(".patch")].replace("\\", "/"), p + + +def custom_path(custom, path): + """custom/, matching existing directory and file names whatever + their case (Wine finds only one of two names that differ in case).""" + cur = custom + parts = path.split("/") + for i, part in enumerate(parts): + hit = None + if os.path.isdir(cur): + for n in os.listdir(cur): + if n.lower() == part.lower(): + hit = n + break + cur = os.path.join(cur, hit or part) + return cur + + +def apply(gamedir): + data = moddata.Data(gamedir) + custom = os.path.join(gamedir, "custom") + for path, recipe in patches(): + raw, real = data.read(path) + if raw is None: + sys.exit(f"ps2data: {path}: not in the game's rez archives") + text = raw.decode("latin-1") + base = os.path.join(MODERN, path + ".patch") + if os.path.exists(base): + text = moddata.apply_patch(text, open(base, encoding="latin-1").read(), path) + text = moddata.apply_patch(text, open(recipe, encoding="latin-1").read(), path) + dest = custom_path(custom, real) + os.makedirs(os.path.dirname(dest), exist_ok=True) + if os.path.lexists(dest): + os.remove(dest) # never write through a hard link + with open(dest, "wb") as f: + f.write(text.encode("latin-1")) + print(f" {os.path.relpath(dest, gamedir)} <- {os.path.relpath(recipe, REPO)}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + apply(sys.argv[1]) diff --git a/build/ps2/ps2dtx.py b/build/ps2/ps2dtx.py new file mode 100644 index 00000000..ce9704fa --- /dev/null +++ b/build/ps2/ps2dtx.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""PS2 NOLF textures (DTX v-5, BPP_32P: 8-bit indices + a "PALLETE32" section +of 256 32-bit entries) -> PC DTX v-5, BPP_32 (BGRA), with mipmaps. + + ps2dtx.py + +Also decodes PC DXT1/3/5 and BPP_32 DTX (for comparisons). +""" +import struct, sys + +HDR = 0xa4 + + +def clut_index(i): + """PS2 CSM1 CLUT storage order: bits 3 and 4 of the index swapped (the + textures are not stored so; measured against PC copies, which also say BGRA).""" + return (i & ~0x18) | ((i & 0x08) << 1) | ((i & 0x10) >> 1) + + +def ps2_read(data, swizzle=False, order='bgra'): + """-> (w, h, [list of mips as bytes RGBA], header bytes).""" + w, h, nm, ns = struct.unpack_from('<4H', data, 8) + bpp = data[0x1a] + if struct.unpack_from('> i), max(1, h >> i) + mips.append((mw, mh, data[p:p + mw * mh])) + p += mw * mh + pal = None + for _ in range(ns): + name = data[p:p + 15].split(b'\0')[0] + ln = struct.unpack_from(' 1 or mips[-1][1] > 1) and len(mips) < 8: + mips.append(downsample(*mips[-1])) + hdr = bytearray(hdr_in[:HDR]) + struct.pack_into('<4H', hdr, 8, w, h, len(mips), 0) + fl = struct.unpack_from(' BGRA + out += b + return bytes(out) + + +def ps2_to_pc(data, swizzle=False, order='bgra'): + if data[0x1a] == 3: + # Already 32-bit, stored BGRA as on the PC (the PS2 reverses it at + # load). Only the alpha needs the PS2 meaning: none at all = opaque, + # 0x80 = opaque. + w, h = struct.unpack_from('> 11) & 31, (c >> 5) & 63, c & 31 + return (r * 255 // 31, g * 255 // 63, b * 255 // 31) + + +def pc_read_top(data): + """Top mip of a PC DTX as (w, h, RGBA bytes). DXT1/3/5 and BPP_32.""" + w, h = struct.unpack_from(' c1 or bpp != 4: + cols = [a, b, tuple((2 * x + y) // 3 for x, y in zip(a, b)), tuple((x + 2 * y) // 3 for x, y in zip(a, b))] + else: + cols = [a, b, tuple((x + y) // 2 for x, y in zip(a, b)), (0, 0, 0)] + for i in range(16): + x, y = bx * 4 + (i & 3), by * 4 + (i >> 2) + if x < w and y < h: + c = cols[(bits >> (2 * i)) & 3] + o = 4 * (y * w + x) + out[o:o + 4] = bytes((c[0], c[1], c[2], 255)) + return w, h, bytes(out) + + +if __name__ == '__main__': + src, dst = sys.argv[1], sys.argv[2] + open(dst, 'wb').write(ps2_to_pc(open(src, 'rb').read())) diff --git a/build/ps2/ps2install.py b/build/ps2/ps2install.py new file mode 100644 index 00000000..2027e1c3 --- /dev/null +++ b/build/ps2/ps2install.py @@ -0,0 +1,881 @@ +#!/usr/bin/env python3 +"""Install the PS2 "Nine Years Ago" levels (Worlds\\thiefmission1-3) into a PC +NOLF game directory: every converted file they need, from the PS2 disc. + + python3 -I build/ps2/ps2install.py --ps2 --game [--out DIR] + python3 -I build/ps2/ps2install.py --ps2 ... --game ... --check [-v] + +--ps2 the PS2 disc image (ISO9660; LITH_PS2.REZ is read straight out of it) + or an extracted LITH_PS2.REZ. US and European discs both work (the + European one: English, directory table 0). +--game the PC game directory: its nolf*.rez are the retail archives the + converters measure against (textures, the PC child models of + hero_thief), and what --check resolves references in. +--out where the files go (default /custom). Directory names are + upper case, or the case of a directory already there (Wine sees one + directory per case-insensitive name). Every file is written to a + temporary name and renamed over the old one, never rewritten in place + (game directories are often hard-linked copies). +--check write nothing; list every file the levels and the attribute patches + (build/ps2/data/ATTRIBUTES/*.patch) reference and where it resolves + (custom dir or retail .rez, case-insensitive). An install ends with + the same check. -v lists the resolved ones too. + +Written (counts are for the US disc): + WORLDS/thiefmission1-3.dat ps2topc (PS2 lightmaps, scale 1.0), the PS2 + string IDs remapped (ps2strings), the door sound + typo Snd\\Doors\\0wood_c.wav fixed + WORLDS/m07s03a, b.dat the same (no remap): the PS2's M07S03, with the + secret basement; WORLD_FIXES + PS2/TEX/... the worlds' textures, the PS2's own: every one the + disc has (ps2_textures); the PC levels keep theirs + models ps2model (MODELS below) + skins PS2-only .dtx the levels, the patches, the + model styles and the code reference (ps2dtx) + sounds the 81 WAVs of sound-map.txt (ps2sound) + INTERFACE/PHOTOS/...pcx weapon/ammo icons (32x32 -> 48x48) and the + loading-screen photo (the 180x148 card of the + PS2's 256x256 texture), 8-bit PCX +""" +import argparse, os, re, struct, sys, time + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(REPO, 'tools')) +import ps2rez # noqa: E402 +import ps2world # noqa: E402 +import ps2topc # noqa: E402 +import ps2dtx # noqa: E402 +import ps2model # noqa: E402 +import ps2sound # noqa: E402 +import ps2strings # noqa: E402 +import abcmodel # noqa: E402 +from rezextract import Rez # noqa: E402 + +# the "Nine years ago" levels (new: their string IDs are remapped), and the +# PS2's two halves of M07S03 with the secret basement under the Baron's +# courtyard (its strings are the PC's own: no remap) +WORLDS = ['thiefmission1', 'thiefmission2', 'thiefmission3', 'm07s03_A', 'm07s03_B'] +REMAP_WORLDS = ('thiefmission1', 'thiefmission2', 'thiefmission3') + + +def pc_world(name): + """The name a PS2 world is installed under: the PS2's halves of M07S03 + under the names the Thief mod gives them (m07s03a, m07s03b), so that the + mod's versions take their place when it is installed too.""" + return {'m07s03_A': 'm07s03a', 'm07s03_B': 'm07s03b'}.get(name, name) + +# PS2 commands the PC must not run: (world, object, property, PS2 value, PC value). +# The PS2 has no loadout screen and sets the inventory in the level; on the PC +# that would throw away the loadout the player chose. In m07s03_A, clean1 +# (after Goodman's coin toss) resets it and gives the barrette and poodle +# back, and Trigger13 (the courtyard shaft, on the way out of the basement) +# resets it again -- taking the basement's weapons, and everything else. +# The PC keeps the player's inventory; the basement's crossbow and rocket +# launcher are one-time weapons of the mission (MISSIONS.txt). +WORLD_FIXES = [ + ('m07s03_A', 'clean1', 'MessageName3', + 'msg player resetinventory;msg Player (AcquireWeapon barrette);msg Player (AcquireWeapon poodle)', ''), + ('m07s03_A', 'Trigger13', 'MessageName3', 'msg player resetinventory', ''), +] + +# (PS2 model, installed name as the game references it, PC child models) +MODELS = [ + ('guns/models_pv/ps2lockpick_pv.ltb', r'Guns\Models_PV\PS2lockpick_pv.abc', []), + ('guns/models_pv/ps2safecracker_pv.ltb', r'Guns\Models_PV\PS2SafeCracker_pv.abc', []), + ('guns/models_hh/ps2lockpick_hh.ltb', r'Guns\Models_HH\PS2lockpick_hh.abc', []), + ('guns/models_hh/ps2safecracker_hh.ltb', r'Guns\Models_HH\PS2SafeCracker_hh.abc', []), + ('interface/models/hud/moneybag.ltb', r'Interface\Models\MoneyBag.abc', []), + ('interface/models/hud/purses_01.ltb', r'Interface\Models\Purses_01.abc', []), + ('interface/models/hud/change_01.ltb', r'Interface\Models\Change_01.abc', []), + ('props/models/moneybag.ltb', r'Props\Models\MoneyBag.abc', []), + ('attachments/models/britishpolicehat.ltb', r'Attachments\Models\BritishPoliceHat.abc', []), + # ModelButes: chars\models\_