translated the remaining german notes, python comments, help and messages to english (in-game strings, parsed report lines and cli values stay german)

This commit is contained in:
MineTec committed 2026-11-07 10:51:14 +01:00
1 parent 86fe26d786
commit 526f6072f6
36 files changed
+1553 -1544

No files matched your search

+10 -10
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Dekodiert Argonaut-ASF-Musik (v2.1, 4-Bit-ADPCM) wie asfcodec.adl aus dem Spiel.
"""Decodes Argonaut ASF music (v2.1, 4-bit ADPCM) like the game's asfcodec.adl.
Nachgebaut aus der Disassembly von asfcodec.adl (Routinen 0x26e01969 / 0x26e01ca1):
Reimplemented from the disassembly of asfcodec.adl (routines 0x26e01969 / 0x26e01ca1):
Filter 0: s = s1 + (n << shift)
Filter 1: s = 2*s1 - s2 + (n << shift) (+ Übertrag aus 'adc', s. u.)
- kein Clipping; Ausgabe wird als 16 Bit gespeichert (Überlauf = Wrap)
- s1 läuft innerhalb eines Blocks ungekürzt im Register weiter,
am Blockanfang wird es aus dem 16-Bit-Puffer gelesen
- s2 wird immer aus dem 16-Bit-Puffer gelesen
Aufruf: asf2wav.py eingabe.asf ausgabe.wav
Filter 1: s = 2*s1 - s2 + (n << shift) (+ carry from 'adc', see below)
- no clipping; output is stored as 16 bit (overflow = wrap)
- within a block s1 carries on untruncated in the register,
at the start of a block it is read from the 16-bit buffer
- s2 is always read from the 16-bit buffer
Usage: asf2wav.py input.asf output.wav
"""
import array
import struct
@@ -33,7 +33,7 @@ def decode(data):
ch = 2 if flags & 2 else 1
p = off + 20
out = array.array('h', bytes(nblocks * 32 * ch * 2))
mem = [[0, 0] for _ in range(ch)] # gespeicherte 16-Bit-Werte: [vorletztes, letztes]
mem = [[0, 0] for _ in range(ch)] # stored 16-bit values: [second to last, last]
o = 0
for _ in range(nblocks):
for c in range(ch):
@@ -42,7 +42,7 @@ def decode(data):
p += 17
cl = (ctl >> 4) + 2
prev2, prev1 = mem[c]
eax = prev1 # Blockanfang: s1 aus dem Speicher
eax = prev1 # block start: s1 from memory
k = o + c
for byte in blk:
for nib in (byte >> 4, byte & 15):
+9 -9
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Entpackt Argonaut-.cor-Bilder (Ein Königreich für ein Lama) nach PNG.
"""Unpacks Argonaut .cor images (Ein Königreich für ein Lama) to PNG.
Aufbau: 8 Byte Signatur 17 98 AF 89 FE 42 D1 12, u32 Version (1),
u32 entpackte Größe, u32 gepackte Größe, dann LZ-Daten:
n < 0x80 -> n Bytes Rohdaten folgen
1LLL oooo oooooooo -> kopiere L+3 Bytes ab o Bytes weiter vorne
Das Ergebnis sind 640x480 Pixel à 3 Byte (RGB) oder à 1 Byte (Graustufen, z. B. 151.cor).
Aufruf: cor2png.py eingabe.cor ausgabe.png (benötigt Pillow)
Layout: 8-byte signature 17 98 AF 89 FE 42 D1 12, u32 version (1),
u32 unpacked size, u32 packed size, then LZ data:
n < 0x80 -> n bytes of raw data follow
1LLL oooo oooooooo -> copy L+3 bytes from o bytes back
The result is 640x480 pixels at 3 bytes (RGB) or 1 byte (greyscale, e.g. 151.cor).
Usage: cor2png.py input.cor output.png (requires Pillow)
"""
import struct
import sys
@@ -16,7 +16,7 @@ MAGIC = bytes.fromhex('1798af89fe42d112')
def unpack(data):
if data[:8] != MAGIC:
raise ValueError('keine COR-Datei')
raise ValueError('not a COR file')
_, size, packed = struct.unpack_from('<III', data, 8)
src = data[20:20 + packed]
out = bytearray()
@@ -37,7 +37,7 @@ def unpack(data):
if __name__ == '__main__':
raw, size = unpack(open(sys.argv[1], 'rb').read())
print(f'{sys.argv[1]}: {len(raw)} von {size} Bytes')
print(f'{sys.argv[1]}: {len(raw)} of {size} bytes')
if len(sys.argv) > 2:
from PIL import Image
mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size]
+79 -77
View File
@@ -1,26 +1,28 @@
#!/usr/bin/env python3
"""Debug-Visualisierung fuer Level-WADs von "Ein Koenigreich fuer ein Lama".
"""Debug visualisation for level WADs of "Ein Koenigreich fuer ein Lama".
Macht Kollisionsgeometrie (auch unsichtbare Waende/Boeden) und unsichtbare Logik-Objekte
(Kameras, Trigger, Ausgaenge, Checkpoints, Kraftzonen ...) im Spiel sichtbar.
Makes collision geometry (including invisible walls/floors) and invisible logic objects
(cameras, triggers, exits, checkpoints, force zones ...) visible in the game.
debugviz.py IN.wad OUT.wad [Optionen] ein Level
debugviz.py --all OUTDIR [--src CD-wads-Ordner] alle Level-WADs (t1l*, t6l*)
debugviz.py IN.wad OUT.wad [options] one level
debugviz.py --all OUTDIR [--src CD-wads-folder] all level WADs (t1l*, t6l*)
Optionen: --no-coll --no-markers --no-fill (nur Kanten) --opaque (Fuellung deckend)
--width 0.03 (Kantenbreite) --offset 0.012 (Abstand entlang der Normale)
--report datei.json
Options: --no-coll --no-markers --no-fill (edges only) --opaque (opaque fill)
--width 0.03 (edge width) --offset 0.012 (offset along the normal)
--report file.json
Prinzip (Details: notes/debugviz.md):
* Kollision: an jedes TRAK-Modell werden Render-Dreiecke angehaengt (Rahmen entlang der
Kanten jedes Kollisionspolygons, leicht entlang der Normale versetzt; bei unsichtbarer
Kollision zusaetzlich halbtransparente Fuellung). Damit stimmen Kachel-Transformation,
Zonen/Sichtbarkeit und Kachel-Wechsel automatisch. Kollisionspolygone werden NICHT
veraendert/ergaenzt. Vertexfarben-Block in MAP waechst je Kachel und Farbsatz mit.
* Objekte: Marker (Wuerfel, Linien, Boxen) als neue Kacheln ohne Kollision, je Rasterzelle
eine Kachel (also in der richtigen Zone). Strats bleiben unveraendert.
* Farben: angehaengte Flachfarb-Texturen (Flags 0x105 deckend, 0x1105 = Blendmodus 1
Alpha-Blending, Alpha im 4. Byte).
Principle (details: notes/debugviz.md):
* Collision: render triangles are appended to every TRAK model (a frame along the
edges of each collision polygon, offset slightly along the normal; invisible
collision additionally gets a semi-transparent fill). This way tile transforms,
zones/visibility and tile swaps are automatically correct. Collision polygons are NOT
changed/added. The vertex colour block in MAP grows accordingly per tile and colour set.
* Objects: markers (cubes, lines, boxes) as new tiles without collision, one tile per
grid cell (so in the right zone). Strats stay unchanged.
* Colours: appended flat colour textures (flags 0x105 opaque, 0x1105 = blend mode 1
alpha blending, alpha in the 4th byte).
Report/JSON keys and category names (boden, wand, ausgang, koll_polys, ...) are German
identifiers and kept as they are.
"""
import argparse
import collections
@@ -40,24 +42,24 @@ import strats as S # noqa: E402
CD_WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads')
# ------------------------------------------------------------------ Farben
# ------------------------------------------------------------------ colours
KIND_COL = {
'boden': (40, 255, 40), # begehbar (Normale steil nach oben)
'schraege': (200, 255, 0), # schraeg
'wand': (0, 150, 255), # Wand
'decke': (255, 130, 0), # Decke / Ueberhang
'boden': (40, 255, 40), # walkable (normal steeply upwards)
'schraege': (200, 255, 0), # slope
'wand': (0, 150, 255), # wall
'decke': (255, 130, 0), # ceiling / overhang
}
INVIS_FILL = (255, 0, 0) # unsichtbare Kollision (keine Render-Flaeche)
INVIS_FILL = (255, 0, 0) # invisible collision (no render surface)
FILL_ALPHA = 96
# Material -> (Name, Farbe). Unbekannte Materialien bekommen Farben aus PALETTE.
# material -> (name, colour). Unknown materials get colours from PALETTE.
MAT_INFO = {
16: ('Ausgang (LevelDoor-Boden)', (190, 60, 255)),
17: ('selektiv 0x11 (nur Objekte mit Flag 0x8000000)', (255, 255, 0)),
18: ('selektiv 0x12 (nicht fuer Objekte mit Flag 0x8000000)', (255, 200, 120)),
30: ('kein Boden / Fall-/Todesebene (0x1e)', (255, 0, 255)),
16: ('exit (LevelDoor floor)', (190, 60, 255)),
17: ('selective 0x11 (only objects with flag 0x8000000)', (255, 255, 0)),
18: ('selective 0x12 (not for objects with flag 0x8000000)', (255, 200, 120)),
30: ('no floor / fall/death plane (0x1e)', (255, 0, 255)),
}
# feste Farben fuer die uebrigen Materialien (Bedeutung im Code nicht geklaert, siehe notes/debugviz.md)
# fixed colours for the remaining materials (meaning in the code not yet known, see notes/debugviz.md)
MAT_FIXED = {
1: (0, 200, 200), 2: (255, 255, 255), 3: (255, 120, 170), 5: (120, 255, 200), 6: (160, 120, 255),
7: (255, 170, 80), 8: (0, 130, 60), 9: (150, 200, 255), 10: (150, 150, 0), 11: (150, 90, 40),
@@ -79,8 +81,8 @@ MARK_COL = {
'objekt': (90, 90, 90),
}
MARK_H = 0.35 # Markerwuerfel ueber dem Strat-Ursprung
MARK_SIZE = 0.05 # halbe Kantenlaenge
MARK_H = 0.35 # marker cube above the strat origin
MARK_SIZE = 0.05 # half edge length
START_T = {'Llama', 'KuscoAndPachaTogetherOnATree', 'RollerCoaster', 'LlamaChase'}
EXIT_T = {'LevelDoor', 'VillageEndDoor', 'EndGameDoor', 'TeleportDoor'}
FORCE_T = {'FallOffNoHurt', 'ObjectDontDropInBox', 'RiverCurrentBox', 'RiverRebound',
@@ -117,10 +119,10 @@ def mark_cat(name):
return None
# ------------------------------------------------------------------ TEXT: Flachfarben anhaengen
# ------------------------------------------------------------------ TEXT: append flat colours
def text_append_flats(d, entries):
"""entries: Liste (r,g,b,alpha|None). Liefert (neues TEXT, erster neuer Index)."""
"""entries: list of (r,g,b,alpha|None). Returns (new TEXT, first new index)."""
o = 0
npal = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npal):
@@ -139,7 +141,7 @@ def text_append_flats(d, entries):
for r, g, b, a in entries:
if a is None:
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
else: # Blendmodus 1 (Bits 12-14) = SRCALPHA/INVSRCALPHA, Alpha = 4. Byte
else: # blend mode 1 (bits 12-14) = SRCALPHA/INVSRCALPHA, alpha = 4th byte
add += struct.pack('<HbbBBBB', 0x1105, -1, -1, r, g, b, a)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(entries)) + d[o + 4:e] + add + d[e:], nt
@@ -156,10 +158,10 @@ class Palette:
if k not in self.idx:
self.idx[k] = len(self.entries)
self.entries.append((*rgb, alpha))
return self.idx[k] # relativ; + base beim Schreiben
return self.idx[k] # relative; + base when writing
# ------------------------------------------------------------------ Geometrie-Helfer
# ------------------------------------------------------------------ geometry helpers
def norm(v):
n = np.linalg.norm(v)
@@ -167,7 +169,7 @@ def norm(v):
class Mesh:
"""Dreiecksliste mit Paletten-Index; Verts (pos, normal)."""
"""Triangle list with palette index; verts (pos, normal)."""
def __init__(self):
self.verts = []
@@ -178,13 +180,13 @@ class Mesh:
return len(self.verts) - 1
def quad(self, p, n, col):
"""4 Punkte (Ring), eine Normale."""
"""4 points (ring), one normal."""
ids = [self.v(q, n) for q in p]
self.tris.append((ids[0], ids[1], ids[2], col))
self.tris.append((ids[0], ids[2], ids[3], col))
def beam(self, a, b, w, col):
"""Linie a->b als Kreuz aus zwei doppelseitigen Streifen."""
"""Line a->b as a cross of two double-sided strips."""
a = np.asarray(a, float); b = np.asarray(b, float)
d = b - a
L = np.linalg.norm(d)
@@ -215,7 +217,7 @@ def seg_split(a, b, maxlen=1.0):
return [(a + (b - a) * k / n, a + (b - a) * (k + 1) / n) for k in range(n)]
# ------------------------------------------------------------------ Kollision klassifizieren
# ------------------------------------------------------------------ classify collision
def kind_of(ny):
if ny >= 0.7:
@@ -236,7 +238,7 @@ def decode_model_coll(m):
ok = len(pts) >= 3 and all(abs(x) < 12 for p in pts for x in p)
P = np.array(pts, float) if ok else None
if ok:
# Flaeche pruefen
# check the surface
ar = 0.0
for k in range(1, len(P) - 1):
ar += np.linalg.norm(np.cross(P[k] - P[0], P[k + 1] - P[0])) / 2
@@ -249,7 +251,7 @@ def decode_model_coll(m):
class RenderIndex:
"""Render-Dreiecke der Welt (Exportraum), nach Rasterzellen gebuckelt."""
"""Render triangles of the world (export space), bucketed by grid cell."""
def __init__(self, trak, mp):
cells = collections.defaultdict(list)
@@ -280,7 +282,7 @@ class RenderIndex:
self.cells = {k: np.array(v) for k, v in cells.items()}
def covered(self, p, n, dist=0.5, cosmin=0.4, tol=0.02):
"""Trifft der Strahl p + t*n (|t| <= dist) ein Render-Dreieck (Neigung zur Normale < ~66 Grad)?"""
"""Does the ray p + t*n (|t| <= dist) hit a render triangle (angle to the normal < ~66 degrees)?"""
ids = set()
for q in (p, p + n * dist, p - n * dist):
k = (int(math.floor(q[0])), int(math.floor(q[2])))
@@ -316,9 +318,9 @@ class RenderIndex:
def classify(trak, mp, decoded):
"""Setzt pro Polygon 'invis' (True = keine Render-Flaeche deckt es)."""
"""Sets 'invis' per polygon (True = no render surface covers it)."""
ri = RenderIndex(trak, mp)
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [sichtbar, unsichtbar]
votes = collections.defaultdict(lambda: [0, 0]) # (model, k) -> [visible, invisible]
inst = collections.defaultdict(list)
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
for i in range(mp['N']):
@@ -326,7 +328,7 @@ def classify(trak, mp, decoded):
if i in altmap:
inst[altmap[i][4]].append(i)
for mi, polys in enumerate(decoded):
tiles = inst.get(mi, [])[:6] # wenige Instanzen reichen
tiles = inst.get(mi, [])[:6] # a few instances are enough
for k, pl in enumerate(polys):
if pl['pts'] is None:
continue
@@ -334,7 +336,7 @@ def classify(trak, mp, decoded):
cen = P.mean(axis=0)
samples = [cen] + [cen + (q - cen) * 0.7 for q in P]
if not tiles:
votes[(mi, k)][1] += 1 # nie platziert: ohne Weltbezug -> als unsichtbar werten
votes[(mi, k)][1] += 1 # never placed: no world reference -> count as invisible
continue
for ti in tiles:
x, y, z, rot, _, _ = mp['A'][ti]
@@ -348,7 +350,7 @@ def classify(trak, mp, decoded):
pl['invis'] = vs[1] > vs[0]
# ------------------------------------------------------------------ Overlay je Modell
# ------------------------------------------------------------------ overlay per model
def mat_color(mat, matcols):
if mat in MAT_INFO:
@@ -367,7 +369,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
ecol = KIND_COL[pl['kind']] if mat == 0 else mat_color(mat, matcols)
off = n * opt.offset
cen = P.mean(axis=0)
# Inkreis-Abschaetzung: kleinster Abstand Mittelpunkt -> Kante
# incircle estimate: smallest distance centre -> edge
rmin = 1e9
for k in range(len(P)):
a, b = P[k], P[(k + 1) % len(P)]
@@ -377,7 +379,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
continue
rmin = min(rmin, np.linalg.norm(np.cross(e, cen - a)) / L)
w = min(opt.width, 0.4 * rmin)
# innere Ecken: Kantengeraden um w nach innen verschoben, mit Flaechenebene schneiden
# inner corners: edge lines shifted inwards by w, intersected with the surface plane
E = c['edges']
m = len(E)
ok = True
@@ -395,7 +397,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
inner = pts2
else:
inner = [cen + (q - cen) * 0.85 for q in P]
O = [mesh.v(q + off, n) for q in P] # Ecken einmal anlegen (Rahmen + Fuellung teilen sie)
O = [mesh.v(q + off, n) for q in P] # create corners once (frame + fill share them)
I = [mesh.v(q + off, n) for q in inner]
ec = pal(ecol)
for k in range(len(P)):
@@ -416,7 +418,7 @@ def poly_overlay(mesh, pl, pal, opt, matcols):
def append_mesh(model, mesh, pal_base, limit=7900):
"""Haengt Mesh an Modell an (Batch-Flag 0x8 bei Texturwechsel / alle 256 Faces)."""
"""Appends a mesh to a model (batch flag 0x8 on texture change / every 256 faces)."""
nv0 = len(model.verts)
if nv0 + len(mesh.verts) > limit:
return 0
@@ -437,7 +439,7 @@ def append_mesh(model, mesh, pal_base, limit=7900):
return len(mesh.verts)
# ------------------------------------------------------------------ Marker fuer Objekte
# ------------------------------------------------------------------ markers for objects
def fx(v):
v = v - (1 << 32) if v >= 1 << 31 else v
@@ -445,15 +447,15 @@ def fx(v):
def strat_markers(ch, wf, pal, stats):
"""Liefert Liste (welt-Mesh-Teile) als dict cell -> Mesh in Weltkoordinaten (Exportraum)."""
"""Returns the world mesh parts as dict cell -> Mesh in world coordinates (export space)."""
mm = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, mapchunk.trak_vertcounts(G.wad_get(ch, 'TRAK')))
st = G.wad_get(ch, 'STPC')
import stpcwalk
lo = stpcwalk.walk(st, wf)['code']
nodes = mm['nodes']
segs = [] # (a, b, breite, farbe) Weltraum Spielkoordinaten (z positiv)
cubes = [] # (c, h, farbe)
quads = [] # (4 Punkte, farbe, alpha)
segs = [] # (a, b, width, colour) world space, game coordinates (z positive)
cubes = [] # (c, h, colour)
quads = [] # (4 points, colour, alpha)
per_cat = collections.Counter()
per_type = collections.Counter()
@@ -478,7 +480,7 @@ def strat_markers(ch, wf, pal, stats):
name = S.strat_name(st, s[6], lo) or '?'
cat = mark_cat(name)
if cat is None:
# alle uebrigen Objekte: kleiner dunkler Punkt am Ursprung (nichts geht verloren)
# all other objects: small dark dot at the origin (nothing gets lost)
per_cat['objekt'] += 1
cubes.append((np.array([fx(s[3]), fx(s[4]), fx(s[5])]) + [0, 0.06, 0], 0.03, MARK_COL['objekt']))
continue
@@ -487,11 +489,11 @@ def strat_markers(ch, wf, pal, stats):
col = MARK_COL[cat]
p = np.array([fx(s[3]), fx(s[4]), fx(s[5])])
rot = s[1] / 4096 * 2 * math.pi
# Marker: Wuerfel + Pfahl
# marker: cube + post
top = p + [0, MARK_H, 0]
if cat == 'start':
# Pfeil flach auf dem Boden (blockiert die Startkamera nicht)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 Grad = +x
# arrow flat on the ground (doesn't block the start camera)
d = np.array([math.sin(rot), 0, math.cos(rot)]) # 0 = +z, 90 degrees = +x
q = np.array([d[2], 0, -d[0]])
g = p + [0, 0.03, 0]
segs.append((g - d * 0.3, g + d * 0.4, 0.025, col))
@@ -530,16 +532,16 @@ def strat_markers(ch, wf, pal, stats):
prev = q
stats['pfadpunkte'] += len(ch_)
# Umgebungszonen (MAP-Block 4, 90 Byte: Zelle x/y/z, Nebel, Musik ...) -> Drahtwuerfel je Zelle
# environment zones (MAP block 4, 90 bytes: cell x/y/z, fog, music ...) -> wire cube per cell
col = MARK_COL['umgebung']
for L in mm['lights']:
cx, cy, cz = L[0], L[1], L[2]
if L[3:6] == (1, 1, 1):
continue # Vorgabe-Eintrag (ganzes Level)
continue # default entry (whole level)
box((cx + 0.04, cy, cz + 0.04), (cx + 0.96, cy + 0.92, cz + 0.96), col, 0.02)
per_cat['umgebung'] += 1
# alles in Zellen buckeln (Exportraum: z negieren)
# bucket everything into cells (export space: negate z)
cells = collections.defaultdict(Mesh)
def cell_of(q):
@@ -554,7 +556,7 @@ def strat_markers(ch, wf, pal, stats):
for c, h, col in cubes:
cells[cell_of(c)].cube(ex(c), h, pal(col))
for pts, col in quads:
# in <=1x1-Stuecke zerlegen
# split into <=1x1 pieces
(x0, y, z0), _, (x1, _, z1), _ = pts
xs = np.arange(math.floor(x0), math.ceil(x1))
zs = np.arange(math.floor(z0), math.ceil(z1))
@@ -575,7 +577,7 @@ def strat_markers(ch, wf, pal, stats):
return cells
# ------------------------------------------------------------------ Hauptablauf
# ------------------------------------------------------------------ main flow
def process(src, dst, opt):
ch = G.wad_read(src)
@@ -592,7 +594,7 @@ def process(src, dst, opt):
if not opt.no_coll:
decoded = [decode_model_coll(m) for m in trak]
classify(trak, mp, decoded)
# Statistik je Kachelinstanz
# statistics per tile instance
inst = collections.Counter(mp['midx'])
for mi, polys in enumerate(decoded):
for pl in polys:
@@ -610,11 +612,11 @@ def process(src, dst, opt):
add_v[mi] = len(mesh.verts)
mesh_store = mesh
trak[mi]._ov = mesh_store
# Marker
# markers
cells = {}
if not opt.no_markers:
cells = strat_markers(ch, wf, pal, stats)
# TEXT erweitern
# extend TEXT
text, base = text_append_flats(G.wad_get(ch, 'TEXT'), pal.entries)
G.wad_set(ch, 'TEXT', text)
for mi, m in enumerate(trak):
@@ -624,7 +626,7 @@ def process(src, dst, opt):
if got == 0:
stats['modelle_zu_gross'] += 1
add_v[mi] = 0
# neue Marker-Kacheln
# new marker tiles
W, H = mp['W'], mp['H']
new_tiles = []
for (cx, cz), mesh in sorted(cells.items()):
@@ -640,7 +642,7 @@ def process(src, dst, opt):
loc = Mesh()
loc.verts = [(v[0] - ox, v[1] - oy, v[2] - oz, v[3], v[4], v[5]) for v in mesh.verts]
loc.tris = mesh.tris
# zu grosse Zellen aufteilen
# split cells that are too large
if len(loc.verts) > 7900:
stats['marker_gekuerzt'] += 1
keep = [t for t in loc.tris if max(t[:3]) < 7900]
@@ -651,7 +653,7 @@ def process(src, dst, opt):
new_tiles.append(dict(x=ox, y=oy, z=oz, rot=0, model=len(trak) - 1,
cols=[[0x00808080] * len(mdl.verts)]))
stats['marker_kacheln'] = len(new_tiles)
# MAP neu
# rebuild MAP
altmap = dict(zip([i for i in range(mp['N']) if mp['A'][i][5]], mp['anim']))
tiles = []
for i in range(mp['N']):
@@ -666,7 +668,7 @@ def process(src, dst, opt):
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, old_vc, tiles, zones='keep', anim=mp['anim'])
G.wad_set(ch, 'TRAK', G.build_trak(trak))
G.wad_set(ch, 'MAP', newmap)
# Gegenprobe: MAP vollstaendig parsen, Strats unveraendert
# cross-check: MAP parses completely, strats unchanged
vc = [len(m.verts) for m in trak]
a = mapchunk.parse(newmap, wf, vc)
b = mapchunk.parse(G.wad_get(G.wad_read(src), 'MAP'), wf, old_vc)
@@ -693,7 +695,7 @@ def main():
ap.add_argument('out', nargs='?')
ap.add_argument('--all', metavar='OUTDIR')
ap.add_argument('--src', default=CD_WADS)
ap.add_argument('--only', help='Kommagetrennte Levelnamen (mit --all)')
ap.add_argument('--only', help='comma-separated level names (with --all)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--no-markers', action='store_true')
ap.add_argument('--no-fill', action='store_true')
@@ -712,12 +714,12 @@ def main():
for f in files:
info = process(f, os.path.join(opt.all, os.path.basename(f)), opt)
infos.append(info)
print('%-13s %5d KB -> %5d KB Koll %5d (unsichtbar %4d) Marker-Kacheln %3d maxV %4d' % (
print('%-13s %5d KB -> %5d KB coll %5d (invisible %4d) marker tiles %3d maxV %4d' % (
info['level'], info['groesse_alt'] // 1024, info['groesse_neu'] // 1024, info.get('koll_polys', 0),
info.get('koll_unsichtbar', 0), info.get('marker_kacheln', 0), info['max_verts_modell']), flush=True)
else:
if not (opt.inp and opt.out):
ap.error('IN.wad OUT.wad oder --all OUTDIR')
ap.error('IN.wad OUT.wad or --all OUTDIR')
info = process(opt.inp, opt.out, opt)
infos.append(info)
print(json.dumps(info, indent=1, ensure_ascii=False))
+13 -13
View File
@@ -23,33 +23,33 @@ for f in sorted(glob.glob(sys.argv[1] + '/*.wad')):
if run > 256: err.append('batch>256 model %d' % mi); break
if mi < len(otrak):
om = otrak[mi]
if m.coll != om.coll: err.append('KOLLISION GEAENDERT model %d' % mi)
if m.coll != om.coll: err.append('COLLISION CHANGED model %d' % mi)
if m.verts[:len(om.verts)] != om.verts or m.faces[:len(om.faces)] != om.faces:
err.append('Originalgeometrie geaendert %d' % mi)
elif any(len(g) for g in m.coll): err.append('neues Modell mit Kollision')
err.append('original geometry changed %d' % mi)
elif any(len(g) for g in m.coll): err.append('new model with collision')
vc = [len(m.verts) for m in trak]
mp = mapchunk.parse(G.wad_get(ch, 'MAP'), wf, vc)
om = mapchunk.parse(G.wad_get(o, 'MAP'), wf, [len(m.verts) for m in otrak])
if mp['rest'] or mapchunk.build(mp, wf) != G.wad_get(ch, 'MAP'): err.append('MAP roundtrip')
for k in ('strats', 'nodes', 'list1', 'lights', 'tab32', 'grid', 'alts', 'strat_x', 'paths', 'x200', 'last'):
if mp[k] != om[k]: err.append('MAP %s geaendert' % k)
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('Kacheln geaendert')
if mp[k] != om[k]: err.append('MAP %s changed' % k)
if mp['tiles'][:len(om['tiles'])] != om['tiles']: err.append('tiles changed')
W, H = mp['W'], mp['H']
for t in mp['tiles']:
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('Kachel ausserhalb'); break
# Farbblock: je Kachel nv*frames
if not (0 <= t[0] - 0.5 < W and 0 <= -t[2] - 0.5 < H) or t[4] >= len(trak): err.append('tile out of bounds'); break
# colour block: nv*frames per tile
gm = G.parse_map_tiles(G.wad_get(ch, 'MAP'), wf, vc)
for i, fr in enumerate(gm['cols']):
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('Farbblock %d' % i); break
# Kollision unveraendert je Kachel-Instanz der Originalkacheln
if any(len(c) != vc[gm['midx'][i]] for c in fr): err.append('colour block %d' % i); break
# collision unchanged per tile instance of the original tiles
for k in ('TEXT',):
pass
others = [t for t, _ in ch if t not in ('TRAK', 'MAP ', 'TEXT')]
for t in others:
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('Chunk %s geaendert' % t)
if G.wad_get(ch, t) != G.wad_get(o, t): err.append('chunk %s changed' % t)
sz = sum(len(G.wad_get(ch, t)) for t in ('TRAK', 'STPC', 'MAP ', 'LGPC'))
print('%-13s %s maxV %4d maxF %4d Modelle %4d Kacheln %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
n, 'OK ' if not err else 'FEHLER ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
print('%-13s %s maxV %4d maxF %4d models %4d tiles %5d TRAK+STPC+MAP+LGPC %.1f MB' % (
n, 'OK ' if not err else 'ERROR ' + '; '.join(sorted(set(err))[:4]), max(vc), max(len(m.faces) for m in trak),
len(trak), len(mp['tiles']), sz / 2**20))
bad += bool(err)
print('Fehler:', bad)
print('errors:', bad)
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Schnell-Disassembler fuer groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
"""Quick disassembler for groove.exe: disx86.py VA [bytes] (VA hex, .text: file = VA-0x400000)"""
import os
import sys
from capstone import Cs, CS_ARCH_X86, CS_MODE_32
+14 -14
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""Erzeugt ein Beispiel-OBJ mit komplett neuer Geometrie fuer geom_import.py.
"""Generates an example OBJ with completely new geometry for geom_import.py.
geom_demo.py basis.wad demo.obj [--plaza]
geom_demo.py base.wad demo.obj [--plaza]
Baut aus Kachel-Vierecken (je 1x1, Textur = ganze Texturkachel, so wie die Engine es will):
- (--plaza) eine Bodenplatte y=0 ueber allen Rasterzellen, die im Basislevel belegt sind
(damit Strats/Startpunkt irgendwo Boden finden)
- einige Bloecke und eine Rampe in der Mitte des belegten Bereichs
Texturen: haeufigste Textur auf nach oben zeigenden Faces (Boden) bzw. auf Seitenflaechen (Wand).
Builds from tile quads (1x1 each, texture = whole texture tile, the way the engine wants it):
- (--plaza) a floor plate at y=0 over all grid cells occupied in the base level
(so strats/start point find floor somewhere)
- a few blocks and a ramp in the middle of the occupied area
Textures: most frequent texture on upward-facing faces (floor) and on side faces (wall).
"""
import argparse
import collections
@@ -24,7 +24,7 @@ class ObjW:
self.v, self.vt, self.f, self.texs = [], [], [], texs
def quad(self, p, tex, mat=0):
"""p: 4 Punkte, Reihenfolge so dass (p1-p0)x(p2-p0) nach aussen zeigt."""
"""p: 4 points, ordered so that (p1-p0)x(p2-p0) points outwards."""
U0, U1, V0, V1 = G.tex_uv(self.texs[tex])
uv = [(U1, V1), (U0, V1), (U0, V0), (U1, V0)]
base = len(self.v)
@@ -33,10 +33,10 @@ class ObjW:
self.f.append((tex, mat, [base + k for k in range(4)]))
def box(self, x0, z0, x1, z1, y0, y1, top, side):
# Koordinaten: x0<x1, z0>z1 (z negativ = weiter im Raster)
# coordinates: x0<x1, z0>z1 (negative z = further into the grid)
self.quad([(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)], top)
for (ax, az), (bx, bz) in [((x0, z0), (x0, z1)), ((x0, z1), (x1, z1)), ((x1, z1), (x1, z0)), ((x1, z0), (x0, z0))]:
# Wand von a nach b, aussen = rechts herum
# wall from a to b, outside = clockwise
n = math.ceil(y1 - y0)
for k in range(n):
ya, yb = y0 + k * (y1 - y0) / n, y0 + (k + 1) * (y1 - y0) / n
@@ -74,7 +74,7 @@ def main():
o.quad([(cx, 0, -cz), (cx + 1, 0, -cz), (cx + 1, 0, -cz - 1), (cx, 0, -cz - 1)], grass)
mx = sorted(c[0] for c in cells)[len(cells) // 2]; mz = sorted(c[1] for c in cells)[len(cells) // 2]
if not a.plaza:
# Stelle suchen, an der das 9x4-Fenster moeglichst flach und voll belegt ist
# find a spot where the 9x4 window is as flat as possible and fully occupied
top = collections.defaultdict(lambda: -99.0)
for t in mp['A']:
if trak[t[4]].faces:
@@ -91,7 +91,7 @@ def main():
best = (spread, cx, cz)
if best:
mx, mz = best[1], best[2]
# Bloecke + Rampe um die Mitte; ohne --plaza auf die vorhandene Geometrie gestellt
# blocks + ramp around the centre; without --plaza placed on top of the existing geometry
b = 0.0
if not a.plaza:
ys = []
@@ -102,12 +102,12 @@ def main():
b = float(round(sorted(ys)[len(ys) * 3 // 4] * 4) / 4) if ys else 0.0
o.box(mx, -mz, mx + 2, -mz - 2, b, b + 1, grass, wall)
o.box(mx + 3, -mz, mx + 4, -mz - 1, b, b + 3, grass, wall)
for k in range(4): # Rampe in x-Richtung, 4 Zellen lang, steigt auf 1
for k in range(4): # ramp in x direction, 4 cells long, rises to 1
y0, y1 = b + k / 4, b + (k + 1) / 4
x = mx - 4 + k
o.quad([(x, y0, -mz), (x + 1, y1, -mz), (x + 1, y1, -mz - 2), (x, y0, -mz - 2)], grass)
o.save(a.out)
print(f'{a.out}: {len(o.f)} Vierecke, Boden-Textur {grass}, Wand-Textur {wall}, Mitte Zelle ({mx},{mz})')
print(f'{a.out}: {len(o.f)} quads, floor texture {grass}, wall texture {wall}, centre cell ({mx},{mz})')
if __name__ == '__main__':
+18 -18
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env python3
"""Level-WAD -> OBJ (+MTL, Texturseiten-PNGs, Vorschaubilder).
"""Level WAD -> OBJ (+MTL, texture page PNGs, preview images).
geom_export.py level.wad outdir [--collision] [--objects] [--no-png] [--size 1200x900]
Erzeugt in outdir:
level.obj / level.mtl Weltgeometrie (TRAK-Kacheln, platziert nach MAP), mit UVs und
Vertexfarben ("v x y z r g b"), ein OBJ-Objekt pro Kachel
page_NN.png Texturseiten (256x256, aus TEXT)
collision.obj (--collision) Kollisionspolygone, Gruppen nach Material
objects.obj (--objects) statische STPC-Objektmodelle nebeneinander
preview_top.png, preview_persp.png texturierte Software-Renderings zur Kontrolle
Writes to outdir:
level.obj / level.mtl world geometry (TRAK tiles, placed per MAP), with UVs and
vertex colours ("v x y z r g b"), one OBJ object per tile
page_NN.png texture pages (256x256, from TEXT)
collision.obj (--collision) collision polygons, grouped by material
objects.obj (--objects) static STPC object models side by side
preview_top.png, preview_persp.png textured software renderings for checking
Koordinaten: Exportraum = MAP-Floats (y oben, rechtshaendig, z < 0). Spielraum = (x, y, -z).
Coordinates: export space = MAP floats (y up, right-handed, z < 0). Game space = (x, y, -z).
Format: notes/geometry.md
"""
import argparse
@@ -27,7 +27,7 @@ import geomlib as G # noqa: E402
def world_tris(trak, mp, texs, use_alt=False):
"""Liefert Liste von Dreiecken: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
"""Returns a list of triangles: (P[3x3], UV[3x2], C[3x3] 0..1, page, texidx, tileidx)."""
out = []
for i in range(mp['N']):
x, y, z, rot, mi, fl = mp['A'][i]
@@ -57,13 +57,13 @@ def world_tris(trak, mp, texs, use_alt=False):
def flat_color(t):
# Flache Farbe (Texturflag Bit0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
# flat colour (texture flag bit 0): u0,u1,v0 = R,G,B (Renderer FUN_00556510)
return (t['u0'] / 255, t['u1'] / 255, t['v0'] / 255)
def write_obj(path, tris, texs, mtlname):
with open(path, 'w') as fo:
fo.write('# Ein Koenigreich fuer ein Lama - Weltgeometrie (geom_export.py)\n')
fo.write('# Ein Koenigreich fuer ein Lama - world geometry (geom_export.py)\n')
fo.write(f'mtllib {mtlname}\n')
vi = 1
cur_tile = None
@@ -96,7 +96,7 @@ def write_mtl(path, tris, texs, npages):
fo.write(f'newmtl flat_{tex}\nKd %.3f %.3f %.3f\n\n' % c)
# ------------------------------------------------------------ Software-Renderer
# ------------------------------------------------------------ software renderer
def render(tris, pages, cam, size, path, texs):
Wd, Hd = size
@@ -134,7 +134,7 @@ def render(tris, pages, cam, size, path, texs):
iz = w0 / cz[0] + w1 / cz[1] + w2 / cz[2]
depth = 1 / np.where(inside, iz, 1e-9)
sub = zb[y0:y1 + 1, x0:x1 + 1]
# Farbe
# colour
nrm = np.cross(np.array(P[1]) - P[0], np.array(P[2]) - P[0])
nl = np.linalg.norm(nrm)
shade = 0.55 + 0.45 * abs(nrm @ L) / nl if nl > 0 else 1
@@ -166,7 +166,7 @@ def main():
ap.add_argument('wad'); ap.add_argument('outdir')
ap.add_argument('--collision', action='store_true')
ap.add_argument('--objects', action='store_true')
ap.add_argument('--no-png', action='store_true', help='keine Seiten-PNGs/Vorschau')
ap.add_argument('--no-png', action='store_true', help='no page PNGs/previews')
ap.add_argument('--size', default='1200x900')
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
@@ -175,8 +175,8 @@ def main():
tris = world_tris(trak, mp, texs)
write_obj(os.path.join(a.outdir, 'level.obj'), tris, texs, 'level.mtl')
write_mtl(os.path.join(a.outdir, 'level.mtl'), tris, texs, len(pages))
print(f'{a.wad}: WFPC={wf:#x} TRAK-Modelle={len(trak)} Kacheln={mp["N"]} Raster={mp["W"]}x{mp["H"]} '
f'Dreiecke={len(tris)} Texturen={len(texs)} Seiten={len(pages)}')
print(f'{a.wad}: WFPC={wf:#x} TRAK models={len(trak)} tiles={mp["N"]} grid={mp["W"]}x{mp["H"]} '
f'triangles={len(tris)} textures={len(texs)} pages={len(pages)}')
if a.collision:
with open(os.path.join(a.outdir, 'collision.obj'), 'w') as fo:
@@ -233,7 +233,7 @@ def main():
P = np.array([p for t in tris for p in t[0]])
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
ext = max(hi[0] - lo[0], hi[2] - lo[2])
# Draufsicht (fast senkrecht, Norden = -z oben)
# top view (almost vertical, north = -z at the top)
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
os.path.join(a.outdir, 'preview_top.png'), texs)
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
+43 -43
View File
@@ -1,31 +1,31 @@
#!/usr/bin/env python3
"""OBJ -> neue Weltgeometrie (TRAK + Kachelteil von MAP) in einer Kopie eines Level-WADs.
"""OBJ -> new world geometry (TRAK + tile part of MAP) in a copy of a level WAD.
geom_import.py basis.wad mesh.obj neu.wad [--default-tex N] [--no-coll] [--mat N]
geom_import.py base.wad mesh.obj new.wad [--default-tex N] [--no-coll] [--mat N]
[--keep-tiles] [--band 6]
Eingabe-OBJ im Exportraum von geom_export.py (y oben, 1 Einheit = 1 Kachel, z < 0 liegt im
Raster; Rasterzelle (cx,cz) = (floor(x), floor(-z)), muss in 0..W-1 / 0..H-1 liegen).
Input OBJ in the export space of geom_export.py (y up, 1 unit = 1 tile, z < 0 lies in the
grid; grid cell (cx,cz) = (floor(x), floor(-z)), must be within 0..W-1 / 0..H-1).
Texturen: Die Engine kennt KEINE freien UVs. Jedes Dreieck zeigt eine Haelfte eines
Texturrechtecks (Eintrag der TEXT-Tabelle), die Ecken werden ueber Face-Flags gewaehlt.
usemtl page_NN -> Texturindex wird aus dem UV-Rechteck (Bounding-Box der Face-UVs)
auf Seite NN gesucht; UVs muessen auf Rechteck-Ecken liegen
(so wie geom_export sie schreibt).
usemtl tex_N -> Texturindex N; UVs (falls vorhanden) waehlen nur die Ecken-Belegung,
ohne UVs wird die Standardbelegung genommen.
usemtl flat_N -> Texturindex N (so exportiert geom_export Flachfarben)
sonst -> --default-tex
Vierecke werden als (0,1,2)+(2,3,0) zerlegt; mit Rechteck-UVs ergibt das genau die
Original-Belegung (Flags 0x800 / 0x810).
Vertexfarben: "v x y z r g b" (0..1, 1.0 = Originalhelligkeit 0x80); sonst 0x80.
Kollision: --coll-obj datei.obj (Polygone, Gruppen "matNN...") oder aus jedem Face ein konvexes Kollisionspolygon (Viereck wenn planar), eingetragen
in jede Rasterzelle, die das Polygon (xz-Bounding-Box) beruehrt. Material: Gruppe/Objekt
"..._matNN" bzw. --mat. (--no-coll: keine Kollision)
Textures: the engine has NO free UVs. Each triangle shows one half of a texture
rectangle (entry of the TEXT table); the corners are chosen via face flags.
usemtl page_NN -> texture index is looked up from the UV rectangle (bounding box of the
face UVs) on page NN; UVs must lie on rectangle corners
(the way geom_export writes them).
usemtl tex_N -> texture index N; UVs (if present) only choose the corner assignment,
without UVs the default assignment is used.
usemtl flat_N -> texture index N (this is how geom_export exports flat colours)
otherwise -> --default-tex
Quads are split as (0,1,2)+(2,3,0); with rectangle UVs this gives exactly the
original assignment (flags 0x800 / 0x810).
Vertex colours: "v x y z r g b" (0..1, 1.0 = original brightness 0x80); otherwise 0x80.
Collision: --coll-obj file.obj (polygons, groups "matNN...") or one convex collision polygon from each face (quad if planar), entered
into every grid cell the polygon (xz bounding box) touches. Material: group/object
"..._matNN" or --mat. (--no-coll: no collision)
Ausgabe: Ein Modell pro belegter (Zelle, Hoehenband). Alle vorhandenen Kacheln werden ersetzt
(ausser --keep-tiles: alte Kacheln + Modelle bleiben, neue kommen dazu).
Strats/Pfade/Zonen (Rest von MAP) und alle anderen Chunks bleiben unveraendert.
Output: one model per occupied (cell, height band). All existing tiles are replaced
(except with --keep-tiles: old tiles + models stay, new ones are added).
Strats/paths/zones (rest of MAP) and all other chunks stay unchanged.
"""
import argparse
import math
@@ -72,7 +72,7 @@ def read_obj(path):
def find_tex(texs, page, uvs):
"""Texturindex auf Seite 'page', dessen Rechteck am besten zu den UVs passt."""
"""Texture index on page 'page' whose rectangle best matches the UVs."""
us = [u * 256 for u, v in uvs]; vs = [v * 256 for u, v in uvs]
best, bd = None, 1e9
for i, t in enumerate(texs):
@@ -85,7 +85,7 @@ def find_tex(texs, page, uvs):
def match_pattern(uvs, rect):
"""Beste (Flags, Rotation) so dass face_uvs(flags) die gegebenen Ecken-UVs trifft."""
"""Best (flags, rotation) so that face_uvs(flags) hits the given corner UVs."""
best = (1e9, 0x800, 0)
for fl in PATTERNS:
ref = G.face_uvs(fl, rect)
@@ -108,14 +108,14 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('base'); ap.add_argument('obj'); ap.add_argument('out')
ap.add_argument('--default-tex', type=int, default=None)
ap.add_argument('--mat', type=int, default=0, help='Kollisionsmaterial (Standard 0)')
ap.add_argument('--mat', type=int, default=0, help='collision material (default 0)')
ap.add_argument('--no-coll', action='store_true')
ap.add_argument('--coll-obj', help='Kollision aus eigenem OBJ (z.B. collision.obj von geom_export) '
'statt aus den Render-Faces')
ap.add_argument('--coll-obj', help='collision from a separate OBJ (e.g. collision.obj from geom_export) '
'instead of the render faces')
ap.add_argument('--keep-tiles', action='store_true')
ap.add_argument('--zones', choices=['flat', 'keep'], default='flat',
help='flat: Zonen-Sichtbarkeit (PVS) abschalten, alles zeichnen; keep: Original')
ap.add_argument('--band', type=float, default=6.0, help='Hoehenband pro Kachel (Einheiten)')
help='flat: disable zone visibility (PVS), draw everything; keep: original')
ap.add_argument('--band', type=float, default=6.0, help='height band per tile (units)')
a = ap.parse_args()
ch, wf, trak, mp = G.load_level(a.base)
@@ -126,9 +126,9 @@ def main():
if deftex is None:
deftex = next(i for i, t in enumerate(texs) if not (t['flags'] & 1) and t['page'] >= 0)
# ---- Dreiecke mit Textur + Flags
# ---- triangles with texture + flags
tris = [] # (pos[3], col[3], tex, flags)
polys = [] # (pos list, mat) fuer Kollision
polys = [] # (pos list, mat) for collision
warn = defaultdict(int)
for vi, ti, mat, grp in faces:
pts = [V[i] for i in vi]; cols = [C[i] for i in vi]
@@ -140,7 +140,7 @@ def main():
if mm and mm.group(1) == 'page' and uvs:
tex, dist = find_tex(texs, int(mm.group(2)), uvs)
if tex is None or dist > 2:
warn['UV-Rechteck nicht gefunden'] += 1
warn['UV rectangle not found'] += 1
elif mm:
tex = int(mm.group(2))
if tex is None:
@@ -153,7 +153,7 @@ def main():
if uvs and not (t['flags'] & 1):
fl, rot, d = match_pattern([uvs[j] for j in idx], rect)
if d > 2.5 / 256:
warn['UVs nicht auf Rechteck-Ecken'] += 1
warn['UVs not on rectangle corners'] += 1
P = [P[(j + rot) % 3] for j in range(3)]; Cc = [Cc[(j + rot) % 3] for j in range(3)]
else:
fl = 0x800 if k % 2 else 0x810
@@ -166,7 +166,7 @@ def main():
m = re.search(r'mat(\d+)', grp or '')
polys.append(([cV[i] for i in vi], int(m.group(1)) if m else a.mat))
# ---- Zellen/Baender
# ---- cells/bands
def key_of(x, y, z):
cx, cz = math.floor(x), math.floor(-z)
return (cx, cz, math.floor(y / a.band))
@@ -184,7 +184,7 @@ def main():
for cz in range(math.floor(-P[:, 2].max()), math.floor(-P[:, 2].min() - 1e-6) + 1):
cells[(cx, cz, band)]['coll'].append((pts, mat))
# ---- Modelle bauen
# ---- build models
models, tiles = [], []
if a.keep_tiles:
models = list(trak)
@@ -194,7 +194,7 @@ def main():
altcols=mp['altcols'].get(i, [])))
for (cx, cz, band), cell in sorted(cells.items()):
if not (0 <= cx < W and 0 <= cz < H):
warn[f'ausserhalb des Rasters {W}x{H}'] += 1
warn[f'outside the {W}x{H} grid'] += 1
continue
allp = [p for t in cell['tris'] for p in t[0]] or [p for c in cell['coll'] for p in c[0]]
ty = float(round(np.median([p[1] for p in allp])))
@@ -218,8 +218,8 @@ def main():
for ids, tex, fl in facelist:
plane = G.face_plane(*[m.verts[i][:3] for i in ids])
st = (texs[tex]['page'], texs[tex]['flags'])
# Bit 0x8 = neuer Render-Batch (Zustand neu laden). Batch-Puffer 0x5d15f8 fasst ~4800
# Ausgabe-Vertices -> spaetestens alle 256 Faces neu starten.
# bit 0x8 = new render batch (reload state). Batch buffer 0x5d15f8 holds ~4800
# output vertices -> restart at least every 256 faces.
if st != prev or len(m.faces) % 256 == 0:
fl |= 0x8
prev = st
@@ -233,7 +233,7 @@ def main():
for pts, mat in cell['coll']:
loc = [(p[0] - ox, p[1] - ty, p[2] - oz) for p in pts]
if max(abs(c) for p in loc for c in p) > 7.9:
warn['Kollisionspolygon > 7.9 Einheiten vom Kachelzentrum (uebersprungen)'] += 1
warn['collision polygon > 7.9 units from tile centre (skipped)'] += 1
continue
recs = []
if len(loc) == 4:
@@ -252,12 +252,12 @@ def main():
r = bytes([r[0] | 4]) + r[1:]
m.coll[0].append(r)
if len(m.verts) > 8000 or len(m.faces) > 65535:
sys.exit('Kachel zu gross (Transformpuffer 0x58b0f0 fasst 8000 Vertices)')
sys.exit('tile too large (transform buffer 0x58b0f0 holds 8000 vertices)')
models.append(m)
tiles.append(dict(x=ox, y=ty, z=oz, rot=0, model=len(models) - 1, cols=[cols]))
if not models:
sys.exit('keine Geometrie')
sys.exit('no geometry')
newtrak = G.build_trak(models)
newmap = G.build_map_tiles(G.wad_get(ch, 'MAP'), wf, [len(m.verts) for m in trak], tiles, a.zones,
mp['anim'] if a.keep_tiles else ())
@@ -265,9 +265,9 @@ def main():
G.wad_set(ch, 'MAP', newmap)
G.wad_write(a.out, ch)
nf = sum(len(m.faces) for m in models); nc = sum(len(m.coll[0]) for m in models)
print(f'{a.out}: {len(models)} Modelle, {len(tiles)} Kacheln, {nf} Dreiecke, {nc} Kollisionspolygone')
print(f'{a.out}: {len(models)} models, {len(tiles)} tiles, {nf} triangles, {nc} collision polygons')
for k, v in warn.items():
print(f' WARNUNG: {k}: {v}x')
print(f' WARNING: {k}: {v}x')
if __name__ == '__main__':
+59 -59
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Geometrie-Bibliothek fuer Level-WADs von "Ein Koenigreich fuer ein Lama" (Croc-2-Engine, PC).
"""Geometry library for level WADs of "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
Format siehe notes/geometry.md. Kurz:
TRAK = Kachel-Modelle (Welt-Geometrie + Kollision), u32 n, n x 0x84 Header, dann Daten
MAP = Kachel-Raster: Platzierung (Position/Rotation/Modell) + Vertexfarben (+ Strats usw.)
STPC = Objekt-Modelle (gleiches Modellformat, Header 0x8c) + Animationen + Strat-Skripte
TEXT = Texturseiten + Texturtabelle (UV-Rechtecke)
Format: see notes/geometry.md. In short:
TRAK = tile models (world geometry + collision), u32 n, n x 0x84 header, then data
MAP = tile grid: placement (position/rotation/model) + vertex colours (+ strats etc.)
STPC = object models (same model format, header 0x8c) + animations + strat scripts
TEXT = texture pages + texture table (UV rectangles)
Alle Parser sind aus groove.exe nachgebaut (FUN_005563f0 TRAK, FUN_0041f770 STPC-Modell,
FUN_0042ac50 MAP, FUN_00406d30 Texturtabelle, FUN_00556510 Kachel-Renderer).
All parsers are reimplemented from groove.exe (FUN_005563f0 TRAK, FUN_0041f770 STPC model,
FUN_0042ac50 MAP, FUN_00406d30 texture table, FUN_00556510 tile renderer).
"""
import math
import struct
@@ -19,7 +19,7 @@ import struct
def wad_read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
assert total == len(d) - 4, 'WAD-Laengenfeld passt nicht'
assert total == len(d) - 4, 'WAD length field does not match'
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')
@@ -56,21 +56,21 @@ def wfpc_flags(chunks):
return struct.unpack('<I', c)[0] if c else 0
# ---------------------------------------------------------------- Modelle
# Header (TRAK 0x84 Byte, STPC 0x8c Byte):
# +0x00 3f Mittelpunkt der Bounding-Box
# +0x0c 24f 8 Ecken der (um 1/128 vergroesserten) Bounding-Box
# ---------------------------------------------------------------- models
# Header (TRAK 0x84 bytes, STPC 0x8c bytes):
# +0x00 3f centre of the bounding box
# +0x0c 24f 8 corners of the bounding box (enlarged by 1/128)
# +0x6c u16 nVerts +0x6e u16 nFaces
# +0x70 u32 (Zeiger Verts, in Datei 0) +0x74 u32 (Zeiger Faces, 0)
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (Kollisionspolygone, 32 Byte)
# +0x7e u16 0 +0x80 u32 (Zeiger Kollision, 0)
# nur STPC: +0x84 u16 ?(meist = nVerts oder 0) +0x86 u16 nX (n*2 Byte Daten vor den Verts)
# +0x88 u32 (Zeiger, 0)
# Daten: [STPC: nX*u16] Verts nV*24 (pos 3f, normal 3f), Faces nF*28, Kollision nCol*32
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f Ebenen-Normale, f d (n.v = d)
# Kollision (32): u8 typ (Bit0: Viereck, sonst Dreieck), u8 Material, 5 x {i8 nx,ny,nz, u8 0,
# i16 d}: Ebene 0 = Flaeche, Ebenen 1..4 = Kanten (nach innen). Normalen *127, d *4096,
# Koordinaten im Spielraum (z gespiegelt ggue. Vertexdaten!).
# +0x70 u32 (verts pointer, 0 in file) +0x74 u32 (faces pointer, 0)
# +0x78 u16 nColA +0x7a u16 nColB +0x7c u16 nColC (collision polygons, 32 bytes)
# +0x7e u16 0 +0x80 u32 (collision pointer, 0)
# STPC only: +0x84 u16 ?(usually = nVerts or 0) +0x86 u16 nX (n*2 bytes of data before the verts)
# +0x88 u32 (pointer, 0)
# Data: [STPC: nX*u16] verts nV*24 (pos 3f, normal 3f), faces nF*28, collision nCol*32
# Face (28): u16 flags, u16 v0,v1,v2, u16 tex, u16 0, 3f plane normal, f d (n.v = d)
# Collision (32): u8 type (bit 0: quad, otherwise triangle), u8 material, 5 x {i8 nx,ny,nz, u8 0,
# i16 d}: plane 0 = surface, planes 1..4 = edges (pointing inwards). Normals *127, d *4096,
# coordinates in game space (z mirrored relative to the vertex data!).
CORNERS = [(-1, -1, 1), (1, -1, 1), (-1, 1, 1), (1, 1, 1), (-1, -1, -1), (1, -1, -1), (-1, 1, -1), (1, 1, -1)]
@@ -79,11 +79,11 @@ class Model:
def __init__(self):
self.verts = [] # [(x,y,z,nx,ny,nz)]
self.faces = [] # [dict(flags,v=(a,b,c),tex,pad,plane=(nx,ny,nz,d))]
self.coll = [[], [], []] # drei Gruppen; je Eintrag 32 Byte roh (bytes)
self.hdr = None # Original-Header (bytes) zum Vergleich
self.coll = [[], [], []] # three groups; 32 raw bytes per entry (bytes)
self.hdr = None # original header (bytes) for comparison
self.extra = b'' # STPC: nX*u16
self.x84 = 0
self.dirty = False # True setzen, wenn Verts geaendert -> Bounding-Box neu
self.dirty = False # set True when verts changed -> recompute bounding box
def _parse_models(d, o, n, hsize):
@@ -115,7 +115,7 @@ def _parse_models(d, o, n, hsize):
def parse_stpc_models(d):
"""Nur die statischen Objektmodelle am Anfang von STPC (u32 n, je 1 Modell mit 0x8c-Header)."""
"""Only the static object models at the start of STPC (u32 n, 1 model each with 0x8c header)."""
n = struct.unpack_from('<I', d, 0)[0]
o = 4
out = []
@@ -139,7 +139,7 @@ def bbox_header(m):
else:
lo = hi = (0.0, 0.0, 0.0)
c = tuple((a + b) / 2 for a, b in zip(lo, hi))
e = 1 / 128 # Original vergroessert die Box um 1/128
e = 1 / 128 # the original enlarges the box by 1/128
h = tuple((b - a) / 2 + e for a, b in zip(lo, hi))
pts = []
for s in CORNERS:
@@ -148,7 +148,7 @@ def bbox_header(m):
def build_models(models, hsize, recompute_bbox=None):
"""recompute_bbox=None: Original-Box behalten, wenn vorhanden und Verts unveraendert."""
"""recompute_bbox=None: keep the original box if present and verts unchanged."""
hdr, data = b'', b''
for m in models:
if recompute_bbox is False or (recompute_bbox is None and m.hdr is not None
@@ -180,17 +180,17 @@ def build_trak(models):
def face_plane(p0, p1, p2):
ax, ay, az = (p1[i] - p0[i] for i in range(3))
bx, by, bz = (p2[i] - p0[i] for i in range(3))
# Wicklung: Original-Normale = (v1-v0) x (v2-v0)
# winding: original normal = (v1-v0) x (v2-v0)
nx, ny, nz = ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx
ln = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
nx, ny, nz = nx / ln, ny / ln, nz / ln
return (nx, ny, nz, nx * p0[0] + ny * p0[1] + nz * p0[2])
# ---------------------------------------------------------------- Kollision
# ---------------------------------------------------------------- collision
def coll_decode(r):
"""32-Byte-Kollisionspolygon -> dict (Ebenen im Vertex-/Exportraum, d.h. z zurueckgespiegelt)."""
"""32-byte collision polygon -> dict (planes in vertex/export space, i.e. z mirrored back)."""
typ, mat = r[0], r[1]
planes = []
for k in range(5):
@@ -201,7 +201,7 @@ def coll_decode(r):
def coll_polygon(c):
"""Eckpunkte aus Flaechenebene + Kantenebenen (Schnitt benachbarter Kanten)."""
"""Corner points from surface plane + edge planes (intersection of adjacent edges)."""
import numpy as np
P = c['plane']; E = c['edges']
pts = []
@@ -217,7 +217,7 @@ def coll_polygon(c):
def coll_encode(typ, mat, plane, edges):
"""Ebenen im Vertexraum -> 32 Byte (z wird fuer den Spielraum gespiegelt)."""
"""Planes in vertex space -> 32 bytes (z is mirrored for game space)."""
def q(p):
nx, ny, nz, d = p
cl = lambda v: max(-127, min(127, int(round(v * 127))))
@@ -227,7 +227,7 @@ def coll_encode(typ, mat, plane, edges):
def coll_from_polygon(pts, mat=0, typ=None):
"""Konvexes Polygon (3 oder 4 Punkte, Vertexraum, Reihenfolge wie Faces) -> 32-Byte-Record."""
"""Convex polygon (3 or 4 points, vertex space, same order as faces) -> 32-byte record."""
import numpy as np
P = [np.array(p, float) for p in pts]
n = np.cross(P[1] - P[0], P[2] - P[0])
@@ -244,20 +244,20 @@ def coll_from_polygon(pts, mat=0, typ=None):
return None
e /= np.linalg.norm(e)
if e @ (cen - a) < 0:
e = -e # nach innen
e = -e # inwards
edges.append((*e, float(e @ a)))
if typ is None:
typ = 1 if len(P) == 4 else 0
return coll_encode(typ, mat, (*n, d), edges)
# ---------------------------------------------------------------- MAP (nur Kachelteile)
STRAT_REC = 90 # Bytes pro Eintrag des 2. Blocks (0x5c im Speicher)
NODE_REC = 34 # Bytes pro Eintrag des 3. Blocks (0x30 im Speicher)
# ---------------------------------------------------------------- MAP (tile parts only)
STRAT_REC = 90 # bytes per entry of the 2nd block (0x5c in memory)
NODE_REC = 34 # bytes per entry of the 3rd block (0x30 in memory)
def parse_map_tiles(d, wfpc, vcounts):
"""Liefert Kachel-Infos + Byte-Offsets der Abschnitte (fuer Splicing)."""
"""Returns tile info + byte offsets of the sections (for splicing)."""
N, W, H = struct.unpack_from('<3I', d, 0)
o = 12
offs = {'A': o}
@@ -299,16 +299,16 @@ def parse_map_tiles(d, wfpc, vcounts):
def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
"""Ersetzt die Kachelabschnitte im MAP-Chunk.
"""Replaces the tile sections in the MAP chunk.
tiles: Liste dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in Kacheleinheiten
(Exportraum: y oben, z<=0), rot in 4096tel (Rotation um y).
Kachel-Wechsel-Animationen: tiles[i]['flag']=1 + tiles[i]['altcols'] (Farben des Alt-Modells);
anim = Liste der 5-u32-Eintraege (Zelle x, y, z, Modell, Alt-Modell), in Reihenfolge der
geflaggten Kacheln. Ohne anim werden alle Flags geloescht.
zones='flat': Sichtbarkeitszonen abschalten (Zonenraster = 0, Maske von Zone 0 = alle),
sonst ('keep') Zonenraster + 32er-Tabelle unveraendert lassen.
Alles andere (Strat-/Knoten-/Lichtbloecke) bleibt unveraendert.
tiles: list of dict(x,y,z,rot,model,cols=[[rgba u32]*nv]) ; x/y/z in tile units
(export space: y up, z<=0), rot in 1/4096 turns (rotation about y).
Tile swap animations: tiles[i]['flag']=1 + tiles[i]['altcols'] (colours of the alt model);
anim = list of the 5-u32 entries (cell x, y, z, model, alt model), in the order of the
flagged tiles. Without anim all flags are cleared.
zones='flat': disable visibility zones (zone grid = 0, mask of zone 0 = all),
otherwise ('keep') leave zone grid + 32-entry table unchanged.
Everything else (strat/node/light blocks) stays unchanged.
"""
m = parse_map_tiles(d, wfpc, old_vcounts)
of = m['offs']
@@ -320,7 +320,7 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
int(round(t['y'] * 4096)), int(round(-t['z'] * 4096))) for t in tiles)
midx = struct.pack('<%dI' % N, *[t['model'] for t in tiles])
if anim and sum(1 for t in tiles if t.get('flag')) != len(anim):
raise ValueError('Anzahl geflaggter Kacheln != Anzahl anim-Eintraege')
raise ValueError('number of flagged tiles != number of anim entries')
animb = (struct.pack('<I', len(anim)) + b''.join(struct.pack('<5I', *e) for e in anim)) if wfpc & 0x10000 else b''
cols = b''
for t in tiles:
@@ -328,13 +328,13 @@ def build_map_tiles(d, wfpc, old_vcounts, tiles, zones='flat', anim=()):
cols += struct.pack('<%dI' % len(frame), *frame)
mid = d[of['A_end']:of['B']]
if zones == 'flat':
g = of['grid'] - of['A_end'] # Offset der Zonentabelle+Raster innerhalb mid
g = of['grid'] - of['A_end'] # offset of zone table+grid within mid
tab = struct.pack('<2I', 0xffffffff, struct.unpack_from('<I', mid, g - 256 + 4)[0]) + mid[g - 248:g]
mid = mid[:g - 256] + tab + bytes(4 * m['W'] * m['H'])
return head + A + mid + B + midx + animb + cols + d[of['col_end']:]
# ---------------------------------------------------------------- TEXT (Texturtabelle + Seiten)
# ---------------------------------------------------------------- TEXT (texture table + pages)
def parse_text(d):
o = 0
@@ -362,11 +362,11 @@ def parse_text(d):
def page_rgba(p):
"""16-Bit-Seite -> numpy RGBA (Annahme: ABGR1555 wie PS1, RLE16 bei flags&0x80)."""
"""16-bit page -> numpy RGBA (assumption: ABGR1555 like PS1, RLE16 if flags&0x80)."""
import numpy as np
w, h = p['w'], p['h']
if p['flags'] == 0:
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # Graustufen-Notloesung
px = np.frombuffer(p['raw'], np.uint8).astype(np.uint16) * 0x421 # greyscale stopgap
elif p['comp']:
v = np.frombuffer(p['raw'], '<u2'); out = np.zeros(w * h, np.uint16)
i = q = 0
@@ -388,17 +388,17 @@ def page_rgba(p):
def tex_uv(t, pw=256, ph=256):
"""UV-Rechteck (U0,U1,V0,V1) wie FUN_00406880 (u1/v1 inklusiv -> +1)."""
"""UV rectangle (U0,U1,V0,V1) like FUN_00406880 (u1/v1 inclusive -> +1)."""
return (t['u0'] / pw, (t['u1'] + 1) / pw, t['v0'] / ph, (t['v1'] + 1) / ph)
def face_uvs(flags, rect):
"""Pro Face-Ecke (v0,v1,v2) die UV aus dem Texturrechteck, nach FUN_00556510.
"""Per face corner (v0,v1,v2) the UV from the texture rectangle, per FUN_00556510.
Bit 0x20 spiegelt U; Bit 0x800 + Bit 0x10 waehlen die Dreieckshaelfte des Vierecks."""
Bit 0x20 mirrors U; bit 0x800 + bit 0x10 select the triangle half of the quad."""
U0, U1, V0, V1 = rect
m = bool(flags & 0x20)
a, b = (U0, U1) if m else (U1, U0) # a = "rechts" ohne Spiegelung
a, b = (U0, U1) if m else (U1, U0) # a = "right" without mirroring
if not (flags & 0x800):
return [(a, V1), (b, V1), (a, V0)]
if not (flags & 0x10):
@@ -406,7 +406,7 @@ def face_uvs(flags, rect):
return [(b, V0), (a, V0), (a, V1)]
# ---------------------------------------------------------------- Welt zusammensetzen
# ---------------------------------------------------------------- assemble the world
def tile_xform(x, y, z, rot):
a = rot / 4096 * 2 * math.pi
+22 -22
View File
@@ -1,19 +1,19 @@
#!/bin/sh
# Mods für "Ein Königreich für ein Lama" ein- und ausschalten.
# Turn mods for "Ein Königreich für ein Lama" on and off.
#
# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren
# lama-mod aus Originalzustand (L: = unveränderte CD-Daten)
# lama-mod an|on enable the mods in ~/.wine-lama/mods/*/
# lama-mod aus|off original state (L: = unmodified CD data)
# lama-mod status
# lama-mod debug an Debug-Ansicht (Kollision/unsichtbare Objekte) zuschalten
# lama-mod debug aus Debug-Ansicht abschalten (andere Mods bleiben, wie sie sind)
# lama-mod debug an enable the debug view (collision/invisible objects)
# lama-mod debug aus disable the debug view (other mods stay as they are)
#
# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter
# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben
# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige.
# Mods mit einer Datei ".aus" im Ordner werden übersprungen (so schaltet
# "lama-mod debug an|aus" die Mod "debug" unabhängig von den anderen).
# Reihenfolge: alphabetisch, spätere Mods gewinnen (testlevel > debug).
# A mod is a folder whose files are laid out like on the CD under
# Setup/Eng/Cd (e.g. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT).
# Activation uses an overlay of symlinks: original files stay untouched,
# mod files are added or replace files of the same name.
# Mods with a file ".aus" ("off") in their folder are skipped (this is how
# "lama-mod debug an|aus" toggles the "debug" mod independently of the others).
# Order: alphabetical, later mods win (testlevel > debug).
set -e
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}"
MODS="$WINEPREFIX/mods"
@@ -34,12 +34,12 @@ overlay_an() {
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then
echo "Mod: $(basename "$m") (abgeschaltet)"
echo "Mod: $(basename "$m") (disabled)"
continue
fi
echo "Mod: $(basename "$m")"
(cd "$m" && find . -path ./src -prune -o -name .aus -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do
# vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen
# replace an existing file of the same name (case-insensitive)
dir=$(dirname "$f"); name=$(basename "$f")
mkdir -p "$OVL/$dir"
find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} +
@@ -48,7 +48,7 @@ overlay_an() {
done
done
ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:"
echo "Mods aktiv (L: -> $OVL)"
echo "Mods active (L: -> $OVL)"
}
case "${1:-status}" in
@@ -58,27 +58,27 @@ an|on)
aus|off)
ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:"
rm -rf "$OVL"
echo "Originalzustand (L: -> $BASE)"
echo "Original state (L: -> $BASE)"
;;
debug)
if [ ! -d "$DBG/wads" ]; then
echo "Debug-Mod fehlt ($DBG/wads)." >&2; exit 1
echo "Debug mod missing ($DBG/wads)." >&2; exit 1
fi
case "${2:-status}" in
an|on)
rm -f "$DBG/.aus"
LEISE=1 overlay_an
echo "Debug-Ansicht AN ($(ls "$DBG/wads" | wc -l) Level). Wirkt beim nächsten Laden eines Levels."
echo "Debug view ON ($(ls "$DBG/wads" | wc -l) levels). Takes effect the next time a level is loaded."
;;
aus|off)
touch "$DBG/.aus"
if [ "$(readlink "$WINEPREFIX/dosdevices/l:")" = "$OVL" ]; then
LEISE=1 overlay_an
fi
echo "Debug-Ansicht AUS."
echo "Debug view OFF."
;;
status)
if [ -e "$DBG/.aus" ]; then echo "Debug-Ansicht: aus"; else echo "Debug-Ansicht: an (wenn Mods aktiv)"; fi
if [ -e "$DBG/.aus" ]; then echo "Debug view: off"; else echo "Debug view: on (if mods are active)"; fi
;;
*)
sed -n '2,9p' "$0"; exit 1 ;;
@@ -88,8 +88,8 @@ status)
echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")"
for m in "$MODS"/*/; do
[ -d "$m" ] || continue
if [ -e "$m/.aus" ]; then s=" (abgeschaltet)"; else s=""; fi
echo "installierte Mod: $(basename "$m")$s"
if [ -e "$m/.aus" ]; then s=" (disabled)"; else s=""; fi
echo "installed mod: $(basename "$m")$s"
done
;;
*)
+129 -126
View File
@@ -1,34 +1,36 @@
#!/usr/bin/env python3
"""lamatest - unbeaufsichtigter Test-Harness fuer "Ein Koenigreich fuer ein Lama".
"""lamatest - unattended test harness for "Ein Koenigreich fuer ein Lama".
Startet groove.exe (Wine) unsichtbar auf Xvfb :5 mit Null-Audio-Sink, laedt den
TestLevel (Welt 9, Kapitel 1) ueber Spielstand-Slot 3 und macht Screenshots.
Starts groove.exe (Wine) invisibly on Xvfb :5 with a null audio sink, loads the
TestLevel (world 9, chapter 1) via save slot 3 and takes screenshots.
lamatest.py start Xvfb + Sink + Spiel, wartet aufs Fenster
lamatest.py shot out.png Screenshot (1920x1080) von :5
lamatest.py key <key> [hold] Taste halten (xdotool-Name, Default 0.12 s)
lamatest.py keys <k1> <k2> ... mehrere Tasten nacheinander (k[:hold])
lamatest.py seq <outdir> "TOKENS" Tastenfolge wie run --seq im laufenden Spiel (shot:/fp:/rj: ...)
lamatest.py tolevel Demo -> Hauptmenue -> Slot 3 -> TestLevel 1
lamatest.py tour <outdir> [n] Rundum-Fotos: drehen/laufen/Kamera
lamatest.py status laeuft was? Fenster? Prozesse?
lamatest.py stop Spiel beenden, Sink entladen, Xvfb killen
lamatest.py run <wad> <outdir> [n] WAD als T1L9M001.wad installieren, starten,
tolevel, tour, Crash-Erkennung, Log sichern
lamatest.py restore-wad Original-T1L9M001.wad (.orig) zurueck
run-Optionen: --seq "Up:1.5 x shot:name sleep=1 ..." statt Tour eine Tastenfolge;
shot:NAME = Foto NAME.jpg, fp:NAME = Foto in Lama-Sicht (Return gehalten),
dialog = Dialog wegklicken, keys wie bei 'keys' (Taste[:Haltezeit]),
Up+x:0.8 = Tasten gleichzeitig, rj:T1:T2 = Anlauf-Sprung (Up halten,
nach T1 s X, dann noch T2 s Up),
run:T:x@1.3,shot@5,Left@3-3.4 = Up T s halten, X/Foto/Taste nach Zeitplan
(run/Left:T:... haelt eine andere Taste)
--tour zusaetzlich zur --seq auch die Standard-Tour
--keep getestete WAD installiert lassen (sonst wird die vorher
installierte T1L9M001.wad wiederhergestellt - nicht .orig!)
lamatest.py start Xvfb + sink + game, waits for the window
lamatest.py shot out.png screenshot (1920x1080) of :5
lamatest.py key <key> [hold] hold a key (xdotool name, default 0.12 s)
lamatest.py keys <k1> <k2> ... several keys one after another (k[:hold])
lamatest.py seq <outdir> "TOKENS" key sequence like run --seq in the running game (shot:/fp:/rj: ...)
lamatest.py tolevel demo -> main menu -> slot 3 -> TestLevel 1
lamatest.py tour <outdir> [n] all-round photos: turn/walk/camera
lamatest.py status anything running? window? processes?
lamatest.py stop quit the game, unload the sink, kill Xvfb
lamatest.py run <wad> <outdir> [n] install WAD as T1L9M001.wad, start,
tolevel, tour, crash detection, save the log
lamatest.py restore-wad restore the original T1L9M001.wad (.orig)
run options: --seq "Up:1.5 x shot:name sleep=1 ..." a key sequence instead of the tour;
shot:NAME = photo NAME.jpg, fp:NAME = photo in llama view (Return held),
dialog = click dialog away, keys as with 'keys' (key[:hold time]),
Up+x:0.8 = keys simultaneously, rj:T1:T2 = running jump (hold Up,
X after T1 s, then Up for another T2 s),
run:T:x@1.3,shot@5,Left@3-3.4 = hold Up for T s, X/photo/key on a schedule
(run/Left:T:... holds a different key)
--tour run the standard tour in addition to --seq
--keep leave the tested WAD installed (otherwise the previously
installed T1L9M001.wad is restored - not .orig!)
Zustand/Logs: $LAMATEST_DIR (Default /tmp/lamatest-$USER).
Doku: ../notes/testharness.md
State/logs: $LAMATEST_DIR (default /tmp/lamatest-$USER).
Report lines (report.txt, "ergebnis: ...") and `status` output stay German: helper
scripts grep for them. OCR expectations are German game text.
Docs: ../notes/testharness.md
"""
import hashlib
import os
@@ -62,7 +64,7 @@ def log(*a):
def die(msg, code=1):
log("FEHLER:", msg)
log("ERROR:", msg)
sys.exit(code)
@@ -85,9 +87,9 @@ def read_pid(name):
return None
# ---------------------------------------------------------------- Prozesse
# ---------------------------------------------------------------- processes
def groove_procs():
"""Alle laufenden groove.exe-Prozesse: [(pid, environ-DISPLAY)]."""
"""All running groove.exe processes: [(pid, environ DISPLAY)]."""
out = []
for p in os.listdir("/proc"):
if not p.isdigit():
@@ -114,7 +116,7 @@ def foreign_groove():
def xdo(*args):
# Sicherheitsnetz: xdotool immer nur gegen :5
# safety net: xdotool only ever against :5
assert ENV["DISPLAY"] == DISPLAY
return sh(["xdotool"] + list(args))
@@ -130,7 +132,8 @@ def game_alive():
def error_dialog():
"""Wine-Fehlerdialog / Absturzfenster auf :5? Liefert Fenstertitel oder None."""
"""Wine error dialog / crash window on :5? Returns the window title or None
(matches English and German Wine dialog titles)."""
r = xdo("search", "--name", ".")
titles = []
for wid in r.stdout.split():
@@ -145,7 +148,7 @@ def error_dialog():
return None
# ---------------------------------------------------------------- Spielstaende
# ---------------------------------------------------------------- save games
def ksv_files():
return sorted(f for f in os.listdir(GAMEDIR) if f.lower().endswith(".ksv"))
@@ -162,11 +165,11 @@ def restore_saves():
for f in os.listdir(SAVEBAK):
src, dst = os.path.join(SAVEBAK, f), os.path.join(GAMEDIR, f)
if not os.path.exists(dst) or md5(src) != md5(dst):
log("Spielstand %s wurde veraendert -> wiederhergestellt" % f)
log("save game %s was modified -> restored" % f)
shutil.copy2(src, dst)
for f in ksv_files():
if not os.path.exists(os.path.join(SAVEBAK, f)):
log("neuer Spielstand %s vom Test -> entfernt" % f)
log("new save game %s from the test -> removed" % f)
os.remove(os.path.join(GAMEDIR, f))
@@ -177,35 +180,35 @@ def md5(p):
def check_slot3():
p = os.path.join(GAMEDIR, "(null)king2.ksv")
if not os.path.exists(p):
die("Slot 3 (%s) fehlt" % p)
die("slot 3 (%s) missing" % p)
b = open(p, "rb").read()
# Kartencursor (+4/+5) bestimmt, was auf der Auswahlkarte vorgewaehlt ist
# map cursor (+4/+5) decides what is preselected on the selection map
if len(b) != 256 or b[4] != 9 or b[5] != 1:
log("WARNUNG: Slot 3: Kartencursor nicht auf Welt 9/Kapitel 1 (Cursor %d/%d, "
"aktuell %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
log("WARNING: slot 3: map cursor not on world 9/chapter 1 (cursor %d/%d, "
"current %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
# ---------------------------------------------------------------- Kommandos
# ---------------------------------------------------------------- commands
def cmd_start(timeout=60):
os.makedirs(STATE, exist_ok=True)
f = foreign_groove()
if f:
die("Elias' eigene Spielinstanz laeuft (pid %s) - Test abgebrochen" % f)
die("Elias' own game instance is running (pid %s) - test aborted" % f)
if game_alive():
log("Spiel laeuft schon auf %s" % DISPLAY)
log("game already running on %s" % DISPLAY)
return
if not os.path.isdir(OVERLAY) or not os.path.realpath(
os.path.join(PREFIX, "dosdevices", "l:")) == os.path.realpath(OVERLAY):
die("Mod ist nicht aktiv (lama-mod an)")
die("mod is not active (lama-mod an)")
check_slot3()
backup_saves()
# Xvfb
if not read_pid("xvfb"):
if os.path.exists("/tmp/.X11-unix/X5") or os.path.exists("/tmp/.X5-lock"):
if sh(["xdpyinfo"]).returncode == 0:
log("fremder X-Server auf :5 laeuft bereits - wird mitbenutzt")
log("foreign X server already running on :5 - sharing it")
else:
die("/tmp/.X5-lock existiert, aber :5 antwortet nicht")
die("/tmp/.X5-lock exists, but :5 does not respond")
else:
xl = open(XVFBLOG, "w")
p = subprocess.Popen(["Xvfb", DISPLAY, "-screen", "0", "1920x1200x24",
@@ -217,16 +220,16 @@ def cmd_start(timeout=60):
break
time.sleep(0.1)
else:
die("Xvfb startet nicht (%s)" % XVFBLOG)
# Null-Sink (nie die App stummschalten!)
die("Xvfb does not start (%s)" % XVFBLOG)
# null sink (never mute the app!)
if not os.path.exists(pidfile("sink")):
r = sh(["pactl", "load-module", "module-null-sink", "sink_name=" + SINK,
"sink_properties=device.description=lama_stumm"])
if r.returncode == 0:
open(pidfile("sink"), "w").write(r.stdout.strip())
else:
log("WARNUNG: Null-Sink nicht ladbar:", r.stderr.strip())
# Spiel
log("WARNING: cannot load null sink:", r.stderr.strip())
# game
wl = open(WINELOG, "w")
p = subprocess.Popen(["wine", GAMEEXE], cwd=GAMEDIR, env=ENV, stdout=wl,
stderr=subprocess.STDOUT, start_new_session=True)
@@ -234,12 +237,12 @@ def cmd_start(timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
if find_window():
log("Fenster da nach %.1f s" % (time.time() - t0))
log("window up after %.1f s" % (time.time() - t0))
return
if p.poll() is not None and not game_alive():
die("wine beendet sich sofort (Log: %s)" % WINELOG)
die("wine exits immediately (log: %s)" % WINELOG)
time.sleep(0.5)
die("kein Groove-Fenster nach %d s" % timeout)
die("no Groove window after %d s" % timeout)
def shot(path):
@@ -253,8 +256,8 @@ def shot(path):
def small_shot(path):
"""Kleiner Screenshot (960x540 JPEG) fuer Serien - Platte ist knapp.
Mit LAMATEST_SHOT=1280 (Umgebung) 1280x720 / Qualitaet 85 (Vorschaubilder)."""
"""Small screenshot (960x540 JPEG) for series - disk space is tight.
With LAMATEST_SHOT=1280 (environment) 1280x720 / quality 85 (preview images)."""
w = os.environ.get("LAMATEST_SHOT")
scale, q = ("%sx" % w, "85") if w else ("50%", "80")
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
@@ -267,7 +270,7 @@ def small_shot(path):
def key(k, hold=0.12):
wid = find_window()
if not wid:
die("kein Groove-Fenster")
die("no Groove window")
xdo("windowfocus", "--sync", wid)
xdo("keydown", k)
time.sleep(float(hold))
@@ -284,7 +287,7 @@ def keys(seq, gap=0.35):
time.sleep(gap)
# Bildvergleich ueber kleine Graustufen-Thumbnails (nur ImageMagick noetig)
# image comparison via small greyscale thumbnails (only ImageMagick needed)
def thumb(path=None):
src = ["import", "-display", DISPLAY, "-window", "root", "-crop", "1920x1080+0+0",
"+repage"] if path is None else ["convert", path]
@@ -304,7 +307,7 @@ def mean(t):
def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
"""Warten bis sich das Bild `still` Sekunden kaum aendert."""
"""Wait until the image barely changes for `still` seconds."""
t0 = time.time()
time.sleep(min_time)
last, since = thumb(), time.time()
@@ -320,9 +323,9 @@ def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
# ---------------------------------------------------------------- Bilderkennung
# ---------------------------------------------------------------- image recognition
def rgb_thumb():
"""48x27 RGB-Thumbnail des aktuellen Bildes (Liste von (r,g,b)-Zeilen)."""
"""48x27 RGB thumbnail of the current image (list of rows of (r,g,b))."""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"1920x1080+0+0", "+repage", "-resize", "48x27!", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
@@ -339,18 +342,18 @@ def _rows_mean(t, ys):
def classify(t=None):
"""Grobe Bildklasse: black / letterbox / map / bright (Menue oder Spiel)."""
"""Rough image class: black / letterbox / map / bright (menu or game)."""
t = t or rgb_thumb()
if t is None:
return "none"
allm = _rows_mean(t, range(27))
if allm < 6:
return "black"
# nur oberste/unterste Thumbnail-Zeile pruefen: Untertitel liegen im unteren Balken
# only check the top/bottom thumbnail row: subtitles sit in the lower bar
top, bot, mid = _rows_mean(t, (0,)), _rows_mean(t, (26,)), _rows_mean(t, range(6, 18))
if top < 4 and bot < 4 and mid > 15:
return "letterbox" # Kamera-Flug / Dialog-Zwischensequenz
# Auswahlkarte Welt 9: fast einfarbig beige (~205,175,110)
return "letterbox" # camera fly-by / dialogue cutscene
# selection map world 9: almost uniformly beige (~205,175,110)
beige = sum(1 for row in t for (r, g, b) in row
if 180 < r < 230 and 150 < g < 200 and 85 < b < 140 and r > g > b)
if beige > 0.75 * 48 * 27:
@@ -359,7 +362,7 @@ def classify(t=None):
def hud_visible():
"""Goldene Energieleiste/Sonnenkrone oben links (Gameplay-HUD)?"""
"""Golden energy bar/sun crown at the top left (gameplay HUD)?"""
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
"900x110+100+10", "+repage", "-resize", "25%", "-depth", "8",
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
@@ -370,8 +373,8 @@ def hud_visible():
def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
"""Text auf dem Bildschirm (tesseract, deu), klein geschrieben.
threshold (Prozent): nur sehr helle Schrift behalten (z. B. markierter Menuepunkt)."""
"""Text on screen (tesseract, deu), lower-cased.
threshold (percent): keep only very bright text (e.g. the highlighted menu item)."""
filt = ["-threshold", "%d%%" % threshold] if threshold else []
raw = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
crop, "+repage", "ppm:-"], stdout=subprocess.PIPE, env=ENV).stdout
@@ -384,7 +387,7 @@ def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
def crashed():
"""None oder Grund-String, wenn das Spiel abgestuerzt ist."""
"""None, or a reason string if the game has crashed (goes into report.txt)."""
if not game_alive():
return "groove.exe-Prozess weg"
e = error_dialog()
@@ -416,16 +419,16 @@ class Timeout(Exception):
pass
SLOT_CROP = "1600x280+160+740" # Textfeld "Gespeichertes Spiel N / Level Kapitel"
SLOT_CROP = "1600x280+160+740" # text field "Gespeichertes Spiel N / Level Kapitel" (German game text)
def slot_text():
# zwei Schwellen kombinieren - der Hintergrund flimmert, OCR schwankt
# combine two thresholds - the background flickers, OCR fluctuates
return ocr(SLOT_CROP, threshold=70, psm=6) + " | " + ocr(SLOT_CROP, threshold=82, psm=6)
def fuzzy_in(word, text, ratio=0.75):
"""word (evtl. mehrere Woerter) unscharf in text enthalten?"""
"""Is word (possibly several words) fuzzily contained in text?"""
import difflib
if word in text:
return True
@@ -446,63 +449,63 @@ def _has(*words):
def tolevel(skip_flyby=True, log_times=None):
"""Hauptmenue -> Spiel starten -> Slot mit TestLevel -> Kapitel 1 -> Gameplay."""
"""Main menu -> "Spiel starten" -> slot with TestLevel -> chapter 1 -> gameplay."""
T = log_times if log_times is not None else {}
t0 = time.time()
# 1) Hauptmenue (Logo ~2 s, Menue nach ~5-6 s). Kommt stattdessen die Demo,
# bringt Return zurueck ins Menue.
# 1) main menu (logo ~2 s, menu after ~5-6 s). If the demo comes instead,
# Return brings you back to the menu.
tx = time.time()
while True:
c = crashed()
if c:
raise Crash(c)
# Hauptmenue: Menuepunkte rechts unten; bei 75 % Schwelle bleibt nur der
# markierte (helle) Punkt lesbar -> prueft zugleich die Cursorposition.
# main menu: menu items at the bottom right; at a 75 % threshold only the
# highlighted (bright) item stays readable -> also checks the cursor position.
txt = ocr("700x300+1150+650", threshold=75, psm=6, scale="100%")
if fuzzy_in("spiel starten", txt):
log("Hauptmenue erkannt")
log("main menu detected")
time.sleep(0.5)
key("Return")
break
if fuzzy_in("gespeichertes", slot_text()):
break # schon im Slot-Bildschirm
break # already on the slot screen
if time.time() - tx > 40:
log("kein Menue erkannt -> Return (Demo/Logo ueberspringen)")
log("no menu detected -> Return (skip demo/logo)")
key("Return")
tx = time.time()
if time.time() - t0 > 90:
raise Timeout("Hauptmenue nicht erreicht")
time.sleep(1)
T["menu"] = round(time.time() - t0, 1)
# 2) Spielstandauswahl: ganz nach links (kein Wrap), dann den Slot mit TestLevel suchen
# 2) save game selection: all the way left (no wrap), then find the slot with TestLevel
wait_for(_has("gespeichertes"), 20, "Spielstandauswahl")
time.sleep(0.4)
# Dreier-Slider ohne Wrap: Slot 3 ist ganz rechts, weiteres Right bleibt dort.
# Also einfach 2x Right (egal wo man startet) und einmal per OCR pruefen.
# three-slot slider without wrap: slot 3 is at the far right, further Right stays there.
# So just press Right twice (wherever you start) and check once via OCR.
def _is_test():
return (fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=70, psm=6))
or fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=82, psm=6)))
for _ in range(2):
key("Right", 0.3) # 0.3 s halten: bei niedriger Bildrate gehen kurze Druecke verloren
key("Right", 0.3) # hold 0.3 s: short presses get lost at low frame rates
time.sleep(0.3)
for i in range(3): # unter Last kann ein Druck verloren gehen -> noch einmal Right
for i in range(3): # under load a press can get lost -> Right once more
time.sleep(0.6)
if _is_test():
log("Slot 3: TestLevel gefunden (Versuch %d)" % (i + 1))
log("slot 3: TestLevel found (attempt %d)" % (i + 1))
break
key("Right", 0.3)
else:
raise Timeout("kein Spielstand mit TestLevel gefunden (Slot 3 kaputt?)")
key("Return")
T["slot"] = round(time.time() - t0, 1)
# 3) Auswahlkarte
# 3) selection map
wait_for(lambda: classify() == "map", 30, "Auswahlkarte")
time.sleep(1.0)
# Vorauswahl auf der Karte = Kartencursor im KSV (+4/+5), geprueft in check_slot3()
# preselection on the map = map cursor in the KSV (+4/+5), checked in check_slot3()
key("Return")
t_load = time.time()
T["map"] = round(t_load - t0, 1)
# 4) Laden -> (Kamera-Flug im Letterbox) -> Gameplay
# 4) loading -> (camera fly-by in letterbox) -> gameplay
skipped = False
good = 0
states = []
@@ -513,16 +516,16 @@ def tolevel(skip_flyby=True, log_times=None):
st = classify()
if not states or states[-1] != st:
states.append(st)
log("Zustand: %s (%.1f s nach Level-Start)" % (st, time.time() - t_load))
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
if st == "letterbox" and skip_flyby and not skipped:
time.sleep(0.5)
key("Return") # Kamera-Flug ueberspringen
key("Return") # skip the camera fly-by
skipped = True
T["flyby"] = round(time.time() - t_load, 1)
elif st == "letterbox" and skipped and time.time() - t_load > 15:
key("x") # Dialog weiterklicken
# Gameplay = helles Bild, kein Letterbox, und HUD sichtbar (oder nach
# Kamera-Flug / spaetestens 25 s - falls das HUD schon ausgeblendet ist)
key("x") # click through the dialogue
# gameplay = bright image, no letterbox, and HUD visible (or after the
# camera fly-by / after 25 s at the latest - in case the HUD has already faded out)
el = time.time() - t_load
if st == "bright" and el > 2 and (hud_visible() or "letterbox" in states or el > 25):
good += 1
@@ -536,7 +539,7 @@ def tolevel(skip_flyby=True, log_times=None):
T["gameplay"] = round(time.time() - t0, 1)
T["load"] = round(time.time() - t_load, 1)
T["states"] = states
log("Gameplay nach %.1f s (Laden %.1f s), Zustaende %s" % (T["gameplay"], T["load"], states))
log("gameplay after %.1f s (loading %.1f s), states %s" % (T["gameplay"], T["load"], states))
return T
@@ -549,7 +552,7 @@ def cmd_tolevel():
def settle_dialog(maxpress=12):
"""Dialog-/Zwischensequenz (Letterbox) wegklicken."""
"""Click away a dialogue/cutscene (letterbox)."""
n = 0
while classify() == "letterbox" and n < maxpress:
key("x")
@@ -559,8 +562,8 @@ def settle_dialog(maxpress=12):
def tour(outdir, n=8, prefix="tour"):
"""Fotoserie: n Bilder Kamera-Rundumblick (A), dann n Bilder Laufen+Drehen.
Liefert Liste der Dateien. Bilder als 960x540-JPEG (Platz sparen)."""
"""Photo series: n images of a camera pan (A), then n images walking+turning.
Returns the list of files. Images as 960x540 JPEG (saves space)."""
os.makedirs(outdir, exist_ok=True)
files = []
@@ -573,13 +576,13 @@ def tour(outdir, n=8, prefix="tour"):
files.append(f)
snap("start")
# Kamera einmal rundum: 0.5 s A-Taste ~ 90 Grad
# camera once all round: 0.5 s of the A key ~ 90 degrees
hold = 2.0 / max(n, 1)
for i in range(n):
key("a", hold)
time.sleep(0.7)
snap("cam%d" % i)
# Laufen: vorwaerts, zwischendurch drehen; Dialoge wegklicken
# walking: forwards, turning in between; click dialogues away
for i in range(n):
key("Up", 1.2)
time.sleep(0.4)
@@ -589,7 +592,7 @@ def tour(outdir, n=8, prefix="tour"):
snap("walk%d" % i)
key("Left" if i % 3 != 2 else "Right", 0.45)
time.sleep(0.3)
# Lama-Sicht (Return gehalten) einmal pro Richtung
# llama view (Return held) once per direction
key("Return", 1.5)
time.sleep(0.2)
snap("lamasicht")
@@ -600,7 +603,7 @@ def cmd_tour(outdir, n):
try:
fs = tour(outdir, n)
except Crash as e:
die("ABSTURZ: %s" % e, 3)
die("CRASH: %s" % e, 3)
print("\n".join(fs))
@@ -610,31 +613,31 @@ def install_wad(wad):
orig = MODWAD + ".orig"
if not os.path.exists(orig):
shutil.copy2(MODWAD, orig)
log("Original gesichert: %s" % orig)
log("original backed up: %s" % orig)
if os.path.abspath(MODWAD) != wad:
shutil.copyfile(wad, MODWAD)
# Overlay zeigt per Symlink auf MODWAD -> neu bauen nur wenn Link fehlt/falsch
# overlay points to MODWAD via symlink -> rebuild only if the link is missing/wrong
link = os.path.join(OVERLAY, "wads", "T1L9M001.wad")
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(MODWAD)):
log("Overlay-Link fehlt -> lama-mod an")
log("overlay link missing -> lama-mod an")
sh([LAMA_MOD, "an"], check=True)
others = [f for f in os.listdir(os.path.dirname(link))
if f.lower() == "t1l9m001.wad" and f != "T1L9M001.wad"]
if others:
log("WARNUNG: weitere Schreibweise im Overlay: %s" % others)
log("WAD installiert: %s (%d Bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
log("WARNING: other spelling in the overlay: %s" % others)
log("WAD installed: %s (%d bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
def cmd_restore_wad():
orig = MODWAD + ".orig"
if not os.path.exists(orig):
die("kein %s" % orig)
die("no %s" % orig)
shutil.copy2(orig, MODWAD)
log("Original-T1L9M001.wad wiederhergestellt")
log("original T1L9M001.wad restored")
def run_seq(outdir, seq):
"""Tastenfolge abarbeiten; 'shot:NAME' fotografiert. Liefert Bilddateien."""
"""Process a key sequence; 'shot:NAME' takes a photo. Returns the image files."""
files = []
for item in seq.split():
c = crashed()
@@ -645,7 +648,7 @@ def run_seq(outdir, seq):
small_shot(f)
files.append(f)
elif item.startswith("fp:"):
# Lama-Sicht: Return halten, waehrenddessen fotografieren
# llama view: hold Return, take the photo meanwhile
wid = find_window()
xdo("windowfocus", "--sync", wid)
xdo("keydown", "Return")
@@ -656,7 +659,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Return")
time.sleep(0.4)
elif item.startswith("rj:"):
# Anlauf-Sprung: Up halten, nach T1 s X tippen, Up noch T2 s halten
# running jump: hold Up, tap X after T1 s, keep holding Up for T2 s
_, t1, t2 = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -669,9 +672,9 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("run:") or item.startswith("run/"):
# Durchlaufen mit Zeitplan: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
# Up (bzw. bei run/Left:... die angegebene Taste) wird T s gehalten; zu den Zeiten (s ab Beginn) X tippen / Foto (seq_NN_runK) /
# Taste von-bis halten. Fotos dauern ~0.2 s, der Zeitplan laeuft weiter (Echtzeit).
# run through with a schedule: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
# Up (or the given key for run/Left:...) is held for T s; at the given times (s from start) tap X / photo (seq_NN_runK) /
# hold a key from-to. Photos take ~0.2 s, the schedule keeps running (real time).
parts = item.split(":", 2)
mk = parts[0][4:] or "Up"
total, ev = float(parts[1]), []
@@ -709,7 +712,7 @@ def run_seq(outdir, seq):
xdo("keyup", arg)
time.sleep(0.3)
elif item.startswith("hop:"):
# Up halten und N-mal alle T s springen (Fotos dazwischen: shot nach dem Token)
# hold Up and jump N times every T s (photos in between: shot after the token)
_, n, t = item.split(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -719,7 +722,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif item.startswith("dj:"):
# Doppelsprung mit Anlauf: Up halten, X, nach 0.3 s nochmal X, Up noch T s
# double jump with run-up: hold Up, X, X again after 0.3 s, Up for another T s
t = float(item.split(":")[1])
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -730,7 +733,7 @@ def run_seq(outdir, seq):
xdo("keyup", "Up")
time.sleep(0.3)
elif "+" in item.split(":")[0]:
# mehrere Tasten gleichzeitig: Up+x:0.8
# several keys simultaneously: Up+x:0.8
ks, _, h = item.partition(":")
wid = find_window()
xdo("windowfocus", "--sync", wid)
@@ -753,8 +756,8 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
rep = ["wad: %s" % os.path.abspath(wad)]
status = 0
if foreign_groove():
die("Elias' eigene Spielinstanz laeuft - abgebrochen")
# vorher installierte WAD merken -> danach zurueck (nicht .orig!)
die("Elias' own game instance is running - aborted")
# remember the previously installed WAD -> put it back afterwards (not .orig!)
prev = os.path.join(STATE, "vorher_T1L9M001.wad")
os.makedirs(STATE, exist_ok=True)
if not keep and os.path.exists(MODWAD):
@@ -775,7 +778,7 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
if do_tour or (do_tour is None and not seq):
fs += tour(outdir, n)
rep.append("bilder: %d" % (len(fs) + 2))
# eingefroren? (Kamera dreht sich -> aufeinanderfolgende Bilder muessen sich aendern)
# frozen? (the camera turns -> consecutive images must differ)
th = [thumb(f) for f in fs]
dmax = max([diff(a, b) for a, b in zip(th, th[1:])] or [0])
rep.append("bildaenderung max: %.1f" % dmax)
@@ -808,12 +811,12 @@ def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
cmd_stop()
finally:
if os.path.exists(WINELOG):
# Log kuerzen: max. 2 MB behalten (Anfang + Ende)
# truncate the log: keep at most 2 MB (start + end)
data = open(WINELOG, "rb").read()
if len(data) > 2_000_000:
data = data[:1_000_000] + b"\n...[gekuerzt]...\n" + data[-1_000_000:]
open(os.path.join(outdir, "wine.log"), "wb").write(data)
# bekannte harmlose Meldungen (laufen auch bei funktionierendem Spiel)
# known harmless messages (also appear when the game works fine)
noise = ("com_get_class_object", "ChangeDisplaySettingsExW",
"wined3d_restore_display_modes", "gstreamer", "MIDIMAP",
"RestoreDisplayMode")
@@ -844,7 +847,7 @@ def cmd_stop():
pass
time.sleep(1)
if f:
log("fremde Instanz laeuft (%s) - kein wineserver -k, nur eigene Prozesse" % f)
log("foreign instance running (%s) - no wineserver -k, only own processes" % f)
for p, d in groove_procs():
if d == DISPLAY:
os.kill(p, signal.SIGKILL)
@@ -875,13 +878,13 @@ def cmd_stop():
os.remove(pidfile("sink"))
except OSError:
pass
# Sicherheitsnetz: Sink per Name entladen, falls Modulnummer verloren
# safety net: unload the sink by name in case the module number got lost
r = sh(["pactl", "list", "short", "modules"])
for line in r.stdout.splitlines():
if "sink_name=" + SINK in line:
sh(["pactl", "unload-module", line.split()[0]])
restore_saves()
log("gestoppt")
log("stopped")
def cmd_status():
@@ -911,7 +914,7 @@ def main(argv):
keys(a)
elif c == "tolevel":
cmd_tolevel()
elif c == "seq": # Tastenfolge wie run --seq, aber im laufenden Spiel: seq <outdir> "TOKENS"
elif c == "seq": # key sequence like run --seq, but in the running game: seq <outdir> "TOKENS"
os.makedirs(a[0], exist_ok=True)
for f in run_seq(a[0], a[1]):
print(f)
@@ -933,7 +936,7 @@ def main(argv):
sys.exit(cmd_run(pos[0], pos[1], int(pos[2]) if len(pos) > 2 else 8, seq, do_tour, keep))
elif c == "restore-wad":
cmd_restore_wad()
elif c == "mean": # Debug: Helligkeit/Diff
elif c == "mean": # debug: brightness/diff
print(mean(thumb(a[0] if a else None)))
else:
print(__doc__)
+109 -108
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env python3
"""levelbuild - eigene Level fuer "Ein Koenigreich fuer ein Lama" bauen (Croc-2-Engine, PC).
"""levelbuild - build custom levels for "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC).
Ein Level wird als Python-Skript beschrieben, das eine Funktion ``build(L)`` enthaelt.
``L`` ist ein :class:`Level` mit Grundkoerpern (Boxen, Plattformen, Rampen, Treppen, ...),
Texturen per Name, automatischer Vertexfarben-Beleuchtung (Sonne, Himmel, Schatten,
Umgebungsverdeckung) und Objekt-Platzierung (Strats per Typname, Parameter vom Original).
A level is described as a Python script containing a function ``build(L)``.
``L`` is a :class:`Level` with primitives (boxes, platforms, ramps, stairs, ...),
textures by name, automatic vertex colour lighting (sun, sky, shadows,
ambient occlusion) and object placement (strats by type name, parameters from the original).
levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info]
levelbuild.py --types [--base ...] platzierbare Strat-Typen des Basislevels
levelbuild.py --textures OUT.png Kontaktabzug der Texturen (mit Namen/Index)
levelbuild.py --types [--base ...] placeable strat types of the base level
levelbuild.py --textures OUT.png texture contact sheet (with names/indices)
Koordinaten (wie im Spiel / bei Strats): x nach Osten, y nach oben, z = Rasterzeile
(positiv). 1 Einheit = 1 Rasterzelle. Das Raster des Basislevels ist 96x96 (x,z in 0..96).
Winkel in Grad (0..360) um die y-Achse.
Doku: ../notes/levelbau.md
Coordinates (as in the game / for strats): x east, y up, z = grid row
(positive). 1 unit = 1 grid cell. The base level's grid is 96x96 (x,z in 0..96).
Angles in degrees (0..360) about the y axis.
Texture names (TEX/FLAT below) are German keys and part of the level script API.
Docs: ../notes/levelbau.md
"""
import argparse
import hashlib
@@ -38,7 +39,7 @@ BASE = os.path.join(WADS, 't1l1m001.wad')
MODWAD = os.path.expanduser('~/.wine-lama/mods/testlevel/wads/T1L9M001.wad')
FX = 4096
# Texturnamen fuer das Dorf (t1l1m001). Kontaktabzug: notes/img/texturen_dorf.png
# texture names for the village (t1l1m001). Contact sheet: notes/img/texturen_dorf.png
TEX = {
'gras': 1, 'gras2': 170, 'gras_hell': 190, 'gras_streifen': 212, 'gras_dunkel': 157,
'gras_fleck': 99, 'blumen': 87, 'blumen2': 81, 'blumen3': 82,
@@ -53,14 +54,14 @@ TEX = {
'inka': 131, 'muster_lila': 202, 'muster_lila2': 208, 'muster_lila3': 200,
'pilz_rot': 136, 'pilz_rot2': 137, 'treppe_stein': 63,
}
# zusaetzliche Flachfarben (werden an die Texturtabelle angehaengt): Name -> (R, G, B)
# additional flat colours (appended to the texture table): name -> (R, G, B)
FLAT = {
'meer': (40, 120, 150), 'meer_tief': (25, 85, 120), 'weiss': (235, 235, 225),
'rot': (190, 50, 40), 'gelb': (230, 190, 60), 'blau': (60, 90, 190), 'schwarz': (20, 20, 20),
'gruen': (70, 140, 50), 'grau': (120, 120, 120),
}
# Strat-Typen, die im Dorf-Code stehen, aber im Dorf nicht platziert sind:
# Typfelder (nvars, Slots, Flags, Zusatz) + Parameter aus einem anderen Level uebernehmen
# strat types present in the village code but not placed in the village:
# take type fields (nvars, slots, flags, extra) + parameters from another level
FOREIGN = {'RubyIdol': 't1l1m002.wad'}
@@ -68,14 +69,14 @@ def deg(a):
return int(round((a % 360) / 360 * 4096)) & 0xfff
# ---------------------------------------------------------------- Geometrie-Sammelbehaelter
# ---------------------------------------------------------------- geometry containers
class Quad:
__slots__ = ('p', 'tex', 'coll', 'mat', 'tint', 'shade', 'double', 'render')
def __init__(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), shade=True, double=False, render=True):
self.p = p # 3 oder 4 Punkte (Spielkoordinaten)
self.p = p # 3 or 4 points (game coordinates)
self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double
self.render = render # False: nur Kollision (unsichtbar)
self.render = render # False: collision only (invisible)
def _sub(a, b):
@@ -92,19 +93,19 @@ def _norm(v):
def to_exp(p):
"""Spielraum (z positiv) -> Exportraum/Vertexraum (z negativ)."""
"""Game space (z positive) -> export/vertex space (z negative)."""
return (p[0], p[1], -p[2])
class Level:
"""Baukasten. Alle Methoden nehmen Spielkoordinaten (x, z = Rasterzelle, y oben)."""
"""Construction kit. All methods take game coordinates (x, z = grid cell, y up)."""
def __init__(self, base=BASE, name='Testwelt', seed=1):
self.base = base
self.name = name
self.rng = random.Random(seed)
self.quads = []
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) fuer Schatten/Umgebungsverdeckung
self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) for shadows/ambient occlusion
self.strats = [] # dict(name, x,y,z, rot, params, wp, variant)
self.kill = None # (y, x0, z0, x1, z1)
self.sun = dict(azimuth=215, elevation=50, color=(1.0, 0.93, 0.80), strength=0.62,
@@ -113,7 +114,7 @@ class Level:
self.flats_used = {}
self._load()
# ------------------------------------------------------------ Basislevel
# ------------------------------------------------------------ base level
def _load(self):
self.ch = G.wad_read(self.base)
self.wf = G.wfpc_flags(self.ch)
@@ -125,7 +126,7 @@ class Level:
st = G.wad_get(self.ch, 'STPC')
self.stpc = st
self.code_lo = stpcwalk.walk(st, self.wf)['code']
self.protos = {} # name -> [instanz-tupel je Code-Variante]
self.protos = {} # name -> [instance tuple per code variant]
for s in self.map['strats']:
nm = (S.strat_name(st, s[6], self.code_lo) or '?@%x' % s[6]).replace('(spawn)', '')
lst = self.protos.setdefault(nm, [])
@@ -153,7 +154,7 @@ class Level:
return sorted(self.protos)
def tex(self, t):
"""Texturname, Index (int) oder Flachfarbe ('meer' / (r,g,b))."""
"""Texture name, index (int) or flat colour ('meer' / (r,g,b))."""
if isinstance(t, int):
return t
if isinstance(t, tuple):
@@ -166,36 +167,36 @@ class Level:
if t not in self.flats_used:
self.flats_used[t] = len(self.texs) + len(self.flats_used)
return self.flats_used[t]
raise KeyError('unbekannte Textur %r (Namen: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
raise KeyError('unknown texture %r (names: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT))))
# ------------------------------------------------------------ Grundbausteine
# ------------------------------------------------------------ basic building blocks
def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True):
"""Ein Viereck p = [unten-links, unten-rechts, oben-rechts, oben-links] von vorne gesehen
(Textur aufrecht). rot = 0..3 dreht die Textur in 90-Grad-Schritten."""
"""A quad p = [bottom-left, bottom-right, top-right, top-left] seen from the front
(texture upright). rot = 0..3 rotates the texture in 90-degree steps."""
p = [tuple(map(float, q)) for q in p]
rot %= 4
p = p[rot:] + p[:rot]
self.quads.append(Quad(p, self.tex(tex), coll, mat, tint, shade, double, render))
def collider(self, p, mat=0, normal=(0, 1, 0)):
"""Unsichtbare Kollisionsflaeche (3/4 Punkte), Vorderseite Richtung 'normal'."""
"""Invisible collision surface (3/4 points), front side towards 'normal'."""
self.quad(_orient([tuple(map(float, q)) for q in p], normal), 'gras', coll=True, mat=mat, render=False)
def _hface(self, x0, z0, x1, z1, y, tex, up=True, step=1.0, rot='random', tint=(1, 1, 1), coll=True, mat=0):
"""Waagerechte Flaeche, in Kacheln (step) unterteilt, an ganzen Zahlen ausgerichtet."""
"""Horizontal surface, subdivided into tiles (step), aligned to integers."""
xs = _cuts(x0, x1, step)
zs = _cuts(z0, z1, step)
for xa, xb in zip(xs, xs[1:]):
for za, zb in zip(zs, zs[1:]):
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 Varianten: weniger Modelle
if up: # von oben gesehen: unten-links = (xa, zb)? -> Normale +y
r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 variants: fewer models
if up: # seen from above: bottom-left = (xa, zb)? -> normal +y
p = [(xa, y, za), (xb, y, za), (xb, y, zb), (xa, y, zb)]
else:
p = [(xa, y, zb), (xb, y, zb), (xb, y, za), (xa, y, za)]
self.quad(_orient(p, (0, 1 if up else -1, 0)), tex, coll=coll, mat=mat, tint=tint, rot=r)
def _vface(self, a, b, y0, y1, tex, top_tex=None, step=1.0, tint=(1, 1, 1), coll=True, mat=0, normal=None):
"""Senkrechte Wand von a=(x,z) nach b=(x,z), aussen = rechts von a->b (von oben, z nach unten)."""
"""Vertical wall from a=(x,z) to b=(x,z), outside = right of a->b (seen from above, z pointing down)."""
L = math.hypot(b[0] - a[0], b[1] - a[1])
if L < 1e-6 or y1 - y0 < 1e-6:
return
@@ -212,13 +213,13 @@ class Level:
self.quad(_orient(p, nn), tx, coll=coll, mat=mat, tint=tint)
def floor(self, x0, z0, x1, z1, y, tex='gras', rot='random', tint=(1, 1, 1), step=1.0, coll=True):
"""Flache Bodenflaeche (nur Oberseite)."""
"""Flat floor surface (top side only)."""
self._hface(x0, z0, x1, z1, y, tex, True, step, rot, tint, coll)
def box(self, x0, z0, x1, z1, y0, y1, top='gras', side='erde', side_top=None, bottom=None,
tint=(1, 1, 1), rot='random', sides='nesw', solid=True, coll=True, mat=0, step=1.0, side_coll=None):
"""Quader. side_top: Textur fuer die oberste Reihe der Seitenwaende (z.B. 'graskante').
bottom: Unterseite zeichnen (fuer schwebende Plattformen). sides: welche Seiten (n=z0,
"""Cuboid. side_top: texture for the top row of the side walls (e.g. 'graskante').
bottom: draw the underside (for floating platforms). sides: which sides (n=z0,
s=z1, w=x0, e=x1)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
@@ -229,7 +230,7 @@ class Level:
self._hface(x0, z0, x1, z1, y0, bottom, False, step, 0, tint, coll, mat)
sc = coll if side_coll is None else side_coll
if side:
# Waende so, dass die Aussenseite rechts liegt
# walls oriented so that the outside is on the right
if 'n' in sides:
self._vface((x1, z0), (x0, z0), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, -1))
if 's' in sides:
@@ -243,18 +244,18 @@ class Level:
def platform(self, x, z, w, d, y, top='gras', side='erde', thick=0.5, side_top='graskante',
bottom='erde', **kw):
"""Schwebende Plattform: Oberkante y, Ecke (x,z), Groesse w x d."""
"""Floating platform: top edge y, corner (x,z), size w x d."""
self.box(x, z, x + w, z + d, y - thick, y, top, side, side_top=side_top, bottom=bottom, **kw)
def pillar(self, x, z, y0, y1, size=1.0, side='stein', top='stein', **kw):
"""Saeule mit Mittelpunkt (x,z)."""
"""Pillar centred at (x,z)."""
h = size / 2
self.box(x - h, z - h, x + h, z + h, y0, y1, top, side, **kw)
def ramp(self, x0, z0, x1, z1, ya, yb, direction='+x', top='sand', side='erde', base=None,
tint=(1, 1, 1), step=1.0, coll=True):
"""Rampe ueber dem Rechteck; steigt in 'direction' (+x,-x,+z,-z) von ya auf yb.
Unterkante 'base' (Standard: ya - 0.5); Seiten und Stirnflaeche werden geschlossen."""
"""Ramp over the rectangle; rises in 'direction' (+x,-x,+z,-z) from ya to yb.
Bottom edge 'base' (default: ya - 0.5); sides and front face are closed."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (min(ya, yb) - 0.5) if base is None else base
@@ -271,7 +272,7 @@ class Level:
for za, zb in zip(zs, zs[1:]):
p = [(xa, h(xa, za), za), (xb, h(xb, za), za), (xb, h(xb, zb), zb), (xa, h(xa, zb), zb)]
self.quad(_orient(p, (0, 1, 0)), top, coll=coll, tint=tint)
# Seiten: Trapeze in Streifen
# sides: trapezoids in strips
edges = [((x1, z0), (x0, z0), (0, 0, -1)), ((x0, z1), (x1, z1), (0, 0, 1)),
((x0, z0), (x0, z1), (-1, 0, 0)), ((x1, z1), (x1, z0), (1, 0, 0))]
for a, b, nn in edges:
@@ -290,15 +291,15 @@ class Level:
def stairs(self, x0, z0, x1, z1, y0, y1, n, direction='+x', top='stein', side='stein2', base=None,
walkable=True, tint=(1, 1, 1)):
"""Treppe mit n Stufen auf dem Rechteck, steigt in 'direction' (+x,-x,+z,-z) von y0 auf y1.
walkable=True: Kollision als unsichtbare Schraege (Lama kann hochlaufen; die Engine hat
keine Stufen-Logik, schon 0.12 hohe Kanten blockieren), sonst echte Stufen (springen)."""
"""Stairs with n steps on the rectangle, rising in 'direction' (+x,-x,+z,-z) from y0 to y1.
walkable=True: collision as an invisible slope (the llama can walk up; the engine has
no step logic, even 0.12-high edges block), otherwise real steps (jump)."""
x0, x1 = min(x0, x1), max(x0, x1)
z0, z1 = min(z0, z1), max(z0, z1)
base = (y0 - 0.5) if base is None else base
ax = direction[1]
neg = direction[0] == '-'
# in lokale Koordinaten u (Steigrichtung 0..1) / v (quer 0..1) abbilden
# map to local coordinates u (rise direction 0..1) / v (across 0..1)
def P(u, v, y):
if neg:
u = 1 - u
@@ -315,18 +316,18 @@ class Level:
yt = y0 + (y1 - y0) * (i + 1) / n
yl = base if i == 0 else y0 + (y1 - y0) * i / n
last = i == n - 1
# Trittflaeche
# tread
q = [P(u0, 0, yt), P(u1, 0, yt), P(u1, 1, yt), P(u0, 1, yt)]
self.quad(_orient(q, (0, 1, 0)), top, coll=(rc or last), tint=tint)
# Setzstufe (Stirnseite), Fuss bis base
# riser (front face), foot down to base
q = [P(u0, 0, base), P(u0, 1, base), P(u0, 1, yt), P(u0, 0, yt)]
self.quad(_orient(q, nrm(-1, 0)), side, coll=rc, tint=tint)
# Seitenwaende dieser Stufe (bis base)
# side walls of this step (down to base)
for v, dv in ((0, -1), (1, 1)):
q = [P(u0, v, base), P(u1, v, base), P(u1, v, yt), P(u0, v, yt)]
self.quad(_orient(q, nrm(0, dv)), side, coll=True, tint=tint)
self.solids.append(_aabb([P(u0, 0, base), P(u1, 1, yt)]))
# Rueckwand
# back wall
q = [P(1, 0, base), P(1, 1, base), P(1, 1, y1), P(1, 0, y1)]
self.quad(_orient(q, nrm(1, 0)), side, coll=True, tint=tint)
if walkable:
@@ -335,14 +336,14 @@ class Level:
self.collider(q)
def fence(self, pts, y, height=0.6, thick=0.12, post='holz_dunkel', rail='holz_maser', posts_every=1.0):
"""Zaun (Pfosten + Querlatte) entlang Punktliste [(x,z),...] auf Hoehe y. Mit Kollision."""
"""Fence (posts + rail) along point list [(x,z),...] at height y. With collision."""
for (ax, az), (bx, bz) in zip(pts, pts[1:]):
L = math.hypot(bx - ax, bz - az)
n = max(1, int(round(L / posts_every)))
for i in range(n + 1):
px, pz = ax + (bx - ax) * i / n, az + (bz - az) * i / n
self.box(px - thick, pz - thick, px + thick, pz + thick, y, y + height, top=post, side=post, rot=0)
# Latte als duenner Quader
# rail as a thin cuboid
t = thick * 0.6
yb = y + height * 0.55
if abs(bx - ax) >= abs(bz - az):
@@ -353,7 +354,7 @@ class Level:
solid=False)
def tree(self, x, z, y, height=2.5, crown=1.4, trunk='rinde', leaves='gras_dunkel', trunk_size=None):
"""Einfacher Klotz-Baum (Stamm + zwei Kronen-Wuerfel)."""
"""Simple blocky tree (trunk + two crown cubes)."""
self.pillar(x, z, y, y + height, size=trunk_size or max(0.15, crown * 0.25), side=trunk, top=trunk)
c = crown / 2
self.box(x - c, z - c, x + c, z + c, y + height - 0.2, y + height + crown * 0.7, top=leaves, side=leaves,
@@ -363,15 +364,15 @@ class Level:
side=leaves, coll=True)
def sea(self, x0, z0, x1, z1, y=-2.0, tex='meer', kill=None, step=4.0):
"""Wasser-/Grundflaeche ohne Kollision (grob unterteilt, step) + toedliche Ebene knapp
darueber. kill=(x0,z0,x1,z1): Bereich der Todesebene (Standard: ganze Flaeche, False: keine).
Die Todesebene wird nur in Zellen ohne durchgehenden Boden angelegt."""
"""Water/ground surface without collision (coarsely subdivided, step) + deadly plane just
above it. kill=(x0,z0,x1,z1): area of the death plane (default: whole surface, False: none).
The death plane is only created in cells without continuous floor."""
self._hface(x0, z0, x1, z1, y, tex, True, step, 0, (1, 1, 1), False)
if kill is not False:
self.kill = (y + 0.05,) + tuple(kill or (x0, z0, x1, z1))
def obj(self, path, offset=(0, 0, 0), scale=1.0, tex='stein', coll=True):
"""OBJ-Datei (Spielkoordinaten, y oben). 'usemtl tex_N' bzw. Texturname als Material."""
"""OBJ file (game coordinates, y up). 'usemtl tex_N' or a texture name as material."""
V, cur = [], tex
for line in open(path, encoding='latin1'):
p = line.split()
@@ -391,9 +392,9 @@ class Level:
for k in range(1, len(pts) - 1):
self.quad([pts[0], pts[k], pts[k + 1]], cur, coll=coll)
# ------------------------------------------------------------ Hoehe
# ------------------------------------------------------------ height
def height_at(self, x, z, below=1e9):
"""Hoechste begehbare Flaeche bei (x,z) unterhalb 'below' (oder None)."""
"""Highest walkable surface at (x,z) below 'below' (or None)."""
best = None
for q in self.quads:
if not q.coll:
@@ -413,14 +414,14 @@ class Level:
best = y
return best
# ------------------------------------------------------------ Objekte
# ------------------------------------------------------------ objects
def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False):
"""Objekt platzieren. y=None: auf die hoechste Flaeche bei (x,z) stellen.
rot: Grad (None = wie im Original). params: Liste u32 (None = Original).
wp: 'relative' (Wegpunktkette des Originals mitverschieben, y auf Boden),
'here' (alle Wegpunkte auf das Objekt), 'none' (-1) oder Liste [(x,z[,y[,rot]]),...]."""
"""Place an object. y=None: put it on the highest surface at (x,z).
rot: degrees (None = as in the original). params: list of u32 (None = original).
wp: 'relative' (move the original's waypoint chain along, y snapped to the ground),
'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot]]),...]."""
if name not in self.protos:
raise KeyError('Strat-Typ %r nicht im Basislevel (vorhanden: %s)' % (name, ', '.join(self.types())))
raise KeyError('strat type %r not in base level (available: %s)' % (name, ', '.join(self.types())))
if y is None:
y = self.height_at(x, z)
if y is None:
@@ -430,8 +431,8 @@ class Level:
return d
def player(self, x, z, rot=0, y=None, cam_dist=8.0):
"""Spielerstart. Der erste Wegpunkt der Lama-Kette ist (im Original) die Startposition der
Kamera -> hinter das Lama legen (Abstand cam_dist), die uebrigen auf das Lama."""
"""Player start. The first waypoint of the llama chain is (in the original) the camera's
start position -> put it behind the llama (distance cam_dist), the rest on the llama."""
d = self.strat('Llama', x, z, y, rot, wp='here')
a = math.radians(rot or 0)
cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a)
@@ -453,7 +454,7 @@ class Level:
yy = self.height_at(p[0], p[1])
self.strat(name, p[0], p[1], (yy or 0) + y_off)
# ------------------------------------------------------------ Bauen
# ------------------------------------------------------------ build
def build(self, out, verbose=True):
geo = self._geometry()
models, tiles = geo
@@ -468,20 +469,20 @@ class Level:
if nm is not None and self.name:
G.wad_set(ch, 'NAME', nm[:4] + self.name.encode('latin1') + b'\0')
G.wad_write(out, ch)
# Gegenprobe: MAP parst wieder vollstaendig
# cross-check: MAP parses back completely
chk = M.parse(mp, self.wf, [len(m.verts) for m in models])
assert not chk['rest'] and len(chk['strats']) == len(self._srecs)
if verbose:
nf = sum(len(m.faces) for m in models)
nc = sum(len(m.coll[0]) for m in models)
print('%s: %d Modelle, %d Kacheln, %d Dreiecke, %d Kollisionspolygone, %d Strats, %d Wegpunkte, '
print('%s: %d models, %d tiles, %d triangles, %d collision polygons, %d strats, %d waypoints, '
'TRAK %d KB, WAD %d KB' % (out, len(models), len(tiles), nf, nc, len(self._srecs),
len(self._nodes), len(trak) // 1024, os.path.getsize(out) // 1024))
return out
# -- Beleuchtung
# -- lighting
def _light(self, P, N, tint):
"""P,N: (n,3) Spielkoordinaten; liefert (n,3) Farbe 1.0 = Texturfarbe."""
"""P,N: (n,3) game coordinates; returns (n,3) colour, 1.0 = texture colour."""
s = self.sun
az, el = math.radians(s['azimuth']), math.radians(s['elevation'])
L = np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)])
@@ -491,12 +492,12 @@ class Level:
hemi = (N[:, 1:2] * 0.5 + 0.5)
amb = gnd + (sky - gnd) * hemi
B = np.array(self.solids, float) if self.solids else np.zeros((0, 6))
# Strahlen: Sonne + Halbkugel
# rays: sun + hemisphere
dirs = _hemi_dirs()
shadow = np.ones(len(P))
ao = np.ones(len(P))
CH = 256
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # raeumlich sortieren -> kleine Chunks
order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # sort spatially -> small chunks
P, N = P[order], N[order]
res_shadow, res_ao = np.ones(len(P)), np.ones(len(P))
ndl = ndl[order]
@@ -517,7 +518,7 @@ class Level:
lit = ndl[i:i + CH] > 0
hit = _ray_boxes(p, np.broadcast_to(L, p.shape), far, 60.0) if len(far) else np.zeros(len(p), bool)
res_shadow[i:i + CH] = np.where(lit & hit, 0.0, 1.0)
# AO: Richtungen in der Halbkugel um n
# AO: directions in the hemisphere around n
w = np.clip(n @ dirs.T, 0, None) # (c, R)
occ = np.zeros(len(p))
for r in range(len(dirs)):
@@ -536,18 +537,18 @@ class Level:
col = amb * ao[:, None] + sc * (s['strength'] * ndl * sh)[:, None] * (0.6 + 0.4 * ao[:, None])
return col * np.asarray(tint)
# -- Kacheln/Modelle
# -- tiles/models
def _geometry(self):
BAND, B0 = 10.0, -3.0 # Hoehenbaender je Kachel: [-3,7), [7,17), ...
BAND, B0 = 10.0, -3.0 # height bands per tile: [-3,7), [7,17), ...
MARGIN = 0.3
tris_by_tile = {}
coll_by_tile = {}
# Dreiecke (Exportraum) + Vertexfarben vorbereiten
# prepare triangles (export space) + vertex colours
allv, alln, allt = [], [], []
faces = []
for q in self.quads:
pts = q.p
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # aussen
n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # outwards
if len(pts) == 4:
tri = [((0, 1, 2), 0x820), ((2, 3, 0), 0x830)]
else:
@@ -566,7 +567,7 @@ class Level:
xs = [a[0] for a in pts]; zs = [a[2] for a in pts]
cy = sum(a[1] for a in pts) / len(pts)
band = math.floor((cy - B0) / BAND)
# Boeden: nur ueberdeckte Zellen; Waende/Schraegen: auch Nachbarzellen (Spielerradius)
# floors: only covered cells; walls/slopes: neighbouring cells too (player radius)
mg = 0.0 if abs(n[1]) > 0.95 else MARGIN
for cx in range(math.floor(min(xs) - mg), math.floor(max(xs) + mg - 1e-6) + 1):
for cz in range(math.floor(min(zs) - mg), math.floor(max(zs) + mg - 1e-6) + 1):
@@ -586,7 +587,7 @@ class Level:
for key in sorted(set(tris_by_tile) | set(coll_by_tile)):
cx, cz, band = key
if not (0 <= cx < self.W and 0 <= cz < self.H):
raise SystemExit('Geometrie ausserhalb des Rasters %dx%d: Zelle %d,%d' % (self.W, self.H, cx, cz))
raise SystemExit('geometry outside the %dx%d grid: cell %d,%d' % (self.W, self.H, cx, cz))
ox, oy, oz = cx + 0.5, B0 + band * BAND + BAND / 2, cz + 0.5
m = G.Model(); m.dirty = True
vmap, cols, nacc = {}, [], []
@@ -629,7 +630,7 @@ class Level:
m.coll[0].append(r)
if not m.faces and not m.coll[0]:
continue
# Box der Kachel muss die Kollisionspolygone umfassen (unbenutzte Hilfsvertices)
# the tile's box must enclose the collision polygons (unused helper vertices)
cpts = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for pts, _ in coll_by_tile.get(key, []) for p in pts]
if cpts:
lo = [min(c[k] for c in cpts) for k in range(3)]
@@ -647,22 +648,22 @@ class Level:
m.verts.append((round(c[0], 5), round(c[1], 5), round(c[2], 5), 0.0, 1.0, 0.0))
cols.append(0x01808080)
if len(m.verts) > 4000:
raise SystemExit('Kachel %s zu gross (%d Vertices)' % (key, len(m.verts)))
if not m.verts: # reine Kollisionskachel braucht keine Vertices
raise SystemExit('tile %s too large (%d vertices)' % (key, len(m.verts)))
if not m.verts: # a collision-only tile needs no vertices
pass
h = _model_key(m)
if h not in cache:
cache[h] = len(models)
models.append(m)
tiles.append(dict(x=ox, y=oy, z=-oz, rot=0, model=cache[h], cols=cols))
# Todesebene (Original-Modell 0 des Dorfes: unsichtbar, Material 0x1e, y_lokal = -2.75)
# death plane (the village's original model 0: invisible, material 0x1e, y_local = -2.75)
if self.kill:
ky, x0, z0, x1, z1 = self.kill
km = self.trak0[0]
kill_model = len(models)
models.append(km)
ty = ky + 2.75
# nur Zellen ohne begehbaren Boden (spart Kacheln; unter Boden faellt man nicht)
# only cells without walkable floor (saves tiles; you can't fall below a floor)
covered = self._covered_cells()
for cx in range(max(0, math.floor(x0)), min(self.W, math.ceil(x1))):
for cz in range(max(0, math.floor(z0)), min(self.H, math.ceil(z1))):
@@ -673,7 +674,7 @@ class Level:
return models, tiles
def _covered_cells(self):
"""Zellen, die vollstaendig von nach oben zeigender Kollision (Boden) bedeckt sind."""
"""Cells fully covered by upward-facing collision (floor)."""
area = {}
for q in self.quads:
if not q.coll or len(q.p) != 4:
@@ -729,7 +730,7 @@ class Level:
tab[0] = 0xffffffff
m['tab32'] = tuple(tab)
m['grid'] = (0,) * (self.W * self.H)
# Strats + Wegpunkte
# strats + waypoints
pool = list(self.pool0)
nodes = []
recs = []
@@ -747,13 +748,13 @@ class Level:
else:
prm = self.pool0[proto[8]:proto[8] + proto[7]] if proto[7] else []
if len(prm) != proto[7]:
raise ValueError('%s braucht %d Parameter, nicht %d' % (d['name'], proto[7], len(prm)))
raise ValueError('%s needs %d parameters, not %d' % (d['name'], proto[7], len(prm)))
if prm:
s[8] = len(pool)
pool.extend(int(v) & 0xffffffff for v in prm)
else:
s[8] = -1
# Wegpunkte
# waypoints
wp = d['wp']
chain = []
if proto[13] >= 0:
@@ -770,7 +771,7 @@ class Level:
px, pz = p[0], p[1]
py = p[2] if len(p) > 2 and p[2] is not None else (self.height_at(px, pz) or d['y'])
pr = deg(p[3]) if len(p) > 3 else s[1]
# Zusatzfelder (Wartezeit/Modus o.ae.) vom Original-Wegpunkt uebernehmen
# copy extra fields (wait time/mode etc.) from the original waypoint
a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0)
new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_])
elif chain and wp != 'none':
@@ -809,7 +810,7 @@ class Level:
return M.build(m, self.wf)
# ---------------------------------------------------------------- Hilfsfunktionen
# ---------------------------------------------------------------- helpers
def _s32(v):
return v - (1 << 32) if v >= 1 << 31 else v
@@ -839,12 +840,12 @@ def _wall_normal(a, b):
def _orient(p, normal):
"""Reihenfolge so, dass die Flaeche nach 'normal' (Spielraum) zeigt. Die Engine rechnet im
Exportraum (z gespiegelt): dort muss (p1-p0)x(p2-p0) nach aussen zeigen, im Spielraum also
nach innen (Spiegelung dreht den Umlaufsinn)."""
"""Order the points so the surface faces 'normal' (game space). The engine works in
export space (z mirrored): there (p1-p0)x(p2-p0) must point outwards, i.e. inwards in
game space (mirroring reverses the winding)."""
n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0]))
if sum(a * b for a, b in zip(n, normal)) > 0:
# spiegeln, Ecke 0 bleibt unten-links -> [1,0,3,2]
# mirror, corner 0 stays bottom-left -> [1,0,3,2]
p = [p[1], p[0], p[3], p[2]] if len(p) == 4 else [p[1], p[0], p[2]]
return p
@@ -892,7 +893,7 @@ def _hemi_dirs():
global _HEMI
if _HEMI is None:
d = []
# Fibonacci-Kugel, 24 Richtungen
# Fibonacci sphere, 24 directions
n = 24
for i in range(n):
y = 1 - 2 * (i + 0.5) / n
@@ -904,7 +905,7 @@ def _hemi_dirs():
def _ray_boxes(p, d, B, tmax):
"""p,d: (n,3); B: (b,6). Liefert bool (n,): trifft irgendeine Box mit 0<t<tmax."""
"""p,d: (n,3); B: (b,6). Returns bool (n,): hits any box with 0<t<tmax."""
if len(p) == 0:
return np.zeros(0, bool)
with np.errstate(divide='ignore', invalid='ignore'):
@@ -931,17 +932,17 @@ def main():
ap.add_argument('level', nargs='?')
ap.add_argument('out', nargs='?')
ap.add_argument('--base', default=BASE)
ap.add_argument('--install', action='store_true', help='als TestLevel (T1L9M001.wad) installieren')
ap.add_argument('--install', action='store_true', help='install as TestLevel (T1L9M001.wad)')
ap.add_argument('--types', action='store_true')
ap.add_argument('--textures', metavar='PNG')
ap.add_argument('--set', action='append', default=[], help='Variable fuer das Levelskript, z.B. --set teil=a')
ap.add_argument('--set', action='append', default=[], help='variable for the level script, e.g. --set teil=a')
a = ap.parse_args()
if a.types:
L = Level(a.base)
for n in L.types():
p = L.protos[n][0]
print('%-28s Varianten %d Parameter %d Wegpunkte %s' % (n, len(L.protos[n]), p[7],
'ja' if p[13] >= 0 else '-'))
print('%-28s variants %d parameters %d waypoints %s' % (n, len(L.protos[n]), p[7],
'yes' if p[13] >= 0 else '-'))
return
if a.textures:
import subprocess
@@ -951,7 +952,7 @@ def main():
print('%4d %s' % (i, names[i]))
return
if not (a.level and a.out):
ap.error('LEVEL.py und OUT.wad angeben')
ap.error('specify LEVEL.py and OUT.wad')
mod = load_script(a.level)
opts = dict(kv.split('=', 1) for kv in a.set)
L = Level(a.base, **getattr(mod, 'LEVEL_ARGS', {}))
@@ -965,14 +966,14 @@ def main():
if not os.path.exists(MODWAD + '.orig'):
shutil.copy2(MODWAD, MODWAD + '.orig')
shutil.copyfile(a.out, MODWAD)
print('installiert ->', MODWAD)
print('installed ->', MODWAD)
kapitel_links()
def kapitel_links(n=49):
"""Kapitel 2..n von Welt 9 als Symlinks auf Kapitel 1 (WAD + Ladebild T1%d%d.COR) in der Mod.
Grund: jeder Ausgang (LevelDoor) schaltet im hoechsten freien Kapitel das naechste frei;
fehlt dessen WAD, stuerzt das Spiel bei der Auswahl ab ("Couldnt load wad")."""
"""Chapters 2..n of world 9 as symlinks to chapter 1 (WAD + loading image T1%d%d.COR) in the mod.
Reason: every exit (LevelDoor) in the highest unlocked chapter unlocks the next one;
if its WAD is missing, the game crashes on selecting it ("Couldnt load wad")."""
wd = os.path.dirname(MODWAD)
mod = os.path.dirname(wd)
neu = 0
@@ -984,7 +985,7 @@ def kapitel_links(n=49):
neu += 1
if neu:
import subprocess
print('%d Kapitel-Links angelegt -> lama-mod an' % neu)
print('%d chapter links created -> lama-mod an' % neu)
subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True,
stdout=subprocess.DEVNULL)
+25 -25
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""Testlevel bauen: in einem leeren Bereich des MAP-Rasters eine Bodenplatte aus Kopien
eines flachen Boden-Tiles anlegen, darauf je einen Strat (Objekt) pro Typ im Raster
aufstellen, den Spieler (Strat "Llama") an den Anfang setzen und einen kleinen
Parkour (schwebende Bodenplatten, treppauf) anhängen. Geometrie/Texturen/Strat-Code
(TRAK/STPC/TEXT …) bleiben die des Quell-Levels; nur der MAP-Chunk wird neu geschrieben.
"""Build a test level: in an empty area of the MAP grid, lay a floor plate made of copies
of a flat floor tile, place one strat (object) per type on it in a grid, put the
player (strat "Llama") at the start and append a small parkour (floating floor
plates, going up). Geometry/textures/strat code (TRAK/STPC/TEXT ...) stay those of
the source level; only the MAP chunk is rewritten.
make_testlevel.py quelle.wad ziel.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
make_testlevel.py source.wad target.wad [--keep-original] [--spacing 3] [--name "TestLevel"]
--keep-original : Original-Strats behalten (sonst nur Spieler + Raster + Cheats)
Strat-Typen werden per Name erkannt (strats.py); Parameter/Typ-Felder werden von der
ersten Original-Instanz kopiert, Wegpunkt-Index bleibt (zeigt ggf. auf Original-Wegpunkte).
--keep-original : keep the original strats (otherwise only player + grid + cheats)
Strat types are recognised by name (strats.py); parameter/type fields are copied from the
first original instance, the waypoint index is kept (may point at original waypoints).
"""
import argparse
import os
@@ -23,7 +23,7 @@ import strats as S # noqa: E402
import wadtool # noqa: E402
FX = 4096
# nicht ins Raster: Kamera/Cutscene/Trigger-Zonen, Spieler selbst
# not in the grid: camera/cutscene/trigger zones, the player itself
SKIP_PREFIX = ('CutScene', 'Camera', 'MainMenu', 'Options', 'Title', 'TheNewGroove', 'Force',
'EndGameTrigger', 'RiverTriggerBox', 'FallOffNoHurt')
KEEP_ORIGINAL = ('CheatTeleport', 'CheatCharge')
@@ -45,7 +45,7 @@ def trak_models(trak):
def find_floor_tile(m, models, force=None):
"""Häufigstes Tile, dessen Modell eine flache 1x1-Fläche ist."""
"""Most frequent tile whose model is a flat 1x1 surface."""
from collections import Counter
flat = set()
for i, vs in enumerate(models):
@@ -54,7 +54,7 @@ def find_floor_tile(m, models, force=None):
min(v[2] for v in vs) <= -0.49 and max(v[2] for v in vs) >= 0.49:
flat.add(i)
c = Counter(mi for t, mi in zip(m['tiles'], m['tilemodel']) if mi in flat and not t[5])
# begehbare Böden liegen bei Modell-y = +0.25 (Strats stehen auf Tile-y + 0.25)
# walkable floors sit at model y = +0.25 (strats stand on tile y + 0.25)
best = [k for k, _ in c.most_common() if abs(models[k][0][1] - 0.25) < 1e-3]
mdl = force if force is not None else (best or [c.most_common(1)[0][0]])[0]
idx = next(i for i, (t, mi) in enumerate(zip(m['tiles'], m['tilemodel'])) if mi == mdl and not t[5])
@@ -68,7 +68,7 @@ def empty_rect(m, w, h):
if all((x, z) not in occ for x in range(x0 - 1, x0 + w + 1)
for z in range(z0 - 1, z0 + h + 1)):
return x0, z0
raise SystemExit(f'kein freier Bereich {w}x{h} im Raster {m["W"]}x{m["H"]}')
raise SystemExit(f'no free {w}x{h} area in the {m["W"]}x{m["H"]} grid')
def main():
@@ -78,8 +78,8 @@ def main():
ap.add_argument('--spacing', type=int, default=3)
ap.add_argument('--keep-original', action='store_true')
ap.add_argument('--name')
ap.add_argument('--floor-model', type=int, help='TRAK-Modellindex für Bodenplatten')
ap.add_argument('--y', type=float, default=None, help='Höhe der Bodenplatte (Tile-y)')
ap.add_argument('--floor-model', type=int, help='TRAK model index for floor plates')
ap.add_argument('--y', type=float, default=None, help='height of the floor plate (tile y)')
a = ap.parse_args()
ch, wf, m, lo, types = S.level_types(a.src)
@@ -90,13 +90,13 @@ def main():
cols = 6
rows = (len(names) + cols - 1) // cols
sp = a.spacing
gw, gh = cols * sp + 2, rows * sp + 4 # Raster + Startstreifen
pk = 14 # Parkour-Streifen
gw, gh = cols * sp + 2, rows * sp + 4 # grid + start strip
pk = 14 # parkour strip
x0, z0 = empty_rect(m, gw + pk, gh)
base = m['tiles'][ftile]
y = a.y if a.y is not None else base[1]
print(f'Boden-Tile #{ftile} Modell {fmodel} (Fläche y+{fsurf}), Bereich x{x0}..{x0 + gw + pk} '
f'z{z0}..{z0 + gh}, Höhe {y}')
print(f'floor tile #{ftile} model {fmodel} (surface y+{fsurf}), area x{x0}..{x0 + gw + pk} '
f'z{z0}..{z0 + gh}, height {y}')
def add_tile(cx, cz, ty):
m['tiles'].append((cx + 0.5, ty, -(cz + 0.5), base[3], fmodel, 0))
@@ -107,7 +107,7 @@ def main():
for cx in range(x0, x0 + gw):
for cz in range(z0, z0 + gh):
add_tile(cx, cz, y)
# Parkour: Trittplatten mit Lücken, je +0.5 höher, dann Zielplattform 3x3
# parkour: stepping plates with gaps, each +0.5 higher, then a 3x3 goal platform
px, pz, py = x0 + gw + 1, z0 + 1, y
steps = []
for i in range(6):
@@ -151,7 +151,7 @@ def main():
s[8] = len(m['list1'])
m['list1'].extend(m['_list1_orig'][pi:pi + s[7]])
new.append(tuple(s))
# Spieler an den Start (Rand des Rasters, Blick ins Raster)
# player at the start (edge of the grid, facing into it)
if PLAYER in types:
new = [s for s in new if S.strat_name(ch['STPC'], s[6], lo) != PLAYER]
new.insert(0, srec(types[PLAYER][0], x0 + 1, z0 + 1, y, ry=types[PLAYER][0][1]))
@@ -161,7 +161,7 @@ def main():
cz = z0 + 3 + (k // cols) * sp
new.append(srec(types[nm][0], cx, cz, y))
placed.append((nm, cx, cz))
# Münzen auf dem Parkour, Ausgang (LevelDoor) auf der Zielplattform
# coins on the parkour, exit (LevelDoor) on the goal platform
if 'Coin' in types:
for (cx, cz, ty) in steps:
new.append(srec(types['Coin'][0], cx, cz, ty + 0.5))
@@ -170,7 +170,7 @@ def main():
m['strats'] = new
m['strat_x'] = max(m['strat_x'], len(new) + 64)
m['vcol'] = b''.join(m['vcol_split'])
for i, t in enumerate(m['tiles']): # Konsistenz
for i, t in enumerate(m['tiles']): # consistency
assert 0 <= int(t[0] - 0.5) < m['W'] and 0 <= int(-t[2] - 0.5) < m['H'], (i, t)
data = M.build(m, wf)
@@ -183,9 +183,9 @@ def main():
c = wadtool.get(wch, 'NAME')
c[1] = c[1][:4] + a.name.encode('latin1') + b'\0'
wadtool.write(a.dst, wch)
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} Typen im Raster) -> {a.dst}')
print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} types in the grid) -> {a.dst}')
for nm, cx, cz in placed:
print(f' {nm:30s} Zelle ({cx},{cz})')
print(f' {nm:30s} cell ({cx},{cz})')
if __name__ == '__main__':
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""MAP-Chunk eines Level-WADs ausgeben.
"""Dump the MAP chunk of a level WAD.
map_dump.py level.wad [--strats] [--tiles] [--lights] [--check]
"""
import os
+14 -14
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""MAP-Chunk von Level-WADs parsen/schreiben (Lama/Croc-2-Engine, PC).
"""Parse/write the MAP chunk of level WADs (Lama/Croc 2 engine, PC).
Nachgebaut aus groove.exe FUN_0042ac50 (MAP-Lader). Hängt ab von
WFPC-Flags (u32 im WFPC-Chunk, Bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
TRAK-Modell-Vertexzahlen (Vertexfarben-Block).
Siehe notes/objects.md.
Reimplemented from groove.exe FUN_0042ac50 (MAP loader). Depends on
WFPC flags (u32 in the WFPC chunk, bits 0x10000, 0x10000000, 0x200000, 0x4000000, 0x10)
TRAK model vertex counts (vertex colour block).
See notes/objects.md.
"""
import struct
@@ -27,16 +27,16 @@ class R:
def trak_vertcounts(trak):
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = Vertexzahl), dann Daten."""
"""TRAK: u32 n, n x 0x84-Header (u16 @0x6c = vertex count), then data."""
n = struct.unpack_from('<I', trak, 0)[0]
return [struct.unpack_from('<H', trak, 4 + i * 0x84 + 0x6c)[0] for i in range(n)]
# Feldnamen
# field names
TILE = ('x', 'y', 'z', 'rot', 'b', 'alt') # 3 f32 + 3 u32
LIGHT = '11I3IH4I2IHHI' # 90 Byte
PATHNODE = 'IIHHH5I' # 34 Byte
STRAT = '3H3iIIi4Iiih' + 'H' # 58 Byte (s.u.)
LIGHT = '11I3IH4I2IHHI' # 90 bytes
PATHNODE = 'IIHHH5I' # 34 bytes
STRAT = '3H3iIIi4Iiih' + 'H' # 58 bytes (see below)
def parse(d, wfpc, vcounts):
@@ -45,7 +45,7 @@ def parse(d, wfpc, vcounts):
nt, W, H = r.f('3I')
m['W'], m['H'] = W, H
m['tiles'] = [r.f('3f3I') for _ in range(nt)]
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # von Strats per Index referenziert
n = r.u32(); m['list1'] = list(r.f('%dI' % n)) # referenced by strats via index
n = r.u32(); m['lights'] = [r.f(LIGHT) for _ in range(n)]
n = r.u32(); m['nodes'] = [r.f(PATHNODE) for _ in range(n)]
m['tab32'] = r.f('64I') # 32 x (u32,u32) -> DAT_006da350
@@ -56,11 +56,11 @@ def parse(d, wfpc, vcounts):
if wfpc & 0x10000:
n = r.u32(); alts = [r.f('5I') for _ in range(n)]
m['alts'] = alts
# Vertexfarben: je Tile nv u32 (Byte3 = Frames), ggf. weitere Frames; dann Alt-Modell
# vertex colours: nv u32 per tile (byte 3 = frames), more frames if any; then alt model
altidx = [i for i, t in enumerate(m['tiles']) if t[5]]
altmap = dict(zip(altidx, alts))
start = r.o
split = [] # Vertexfarben-Bytes je Tile (inkl. Alt-Modell)
split = [] # vertex colour bytes per tile (incl. alt model)
for i in range(nt):
t0 = r.o
for mdl in [m['tilemodel'][i]] + ([altmap[i][4]] if i in altmap else []):
@@ -96,7 +96,7 @@ def parse(d, wfpc, vcounts):
def build(m, wfpc):
"""Gegenstück zu parse(); m['vcol'] muss zu tilemodel/alts passen (Rohbytes)."""
"""Counterpart of parse(); m['vcol'] must match tilemodel/alts (raw bytes)."""
P = struct.pack
o = [P('<3I', len(m['tiles']), m['W'], m['H'])]
o += [P('<3f3I', *t) for t in m['tiles']]
+8 -8
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Packt ein Bild als Argonaut-.cor (Gegenstück zu cor2png.py).
"""Packs an image as Argonaut .cor (counterpart of cor2png.py).
Das Bild wird auf 640x480 RGB gebracht und mit dem LZ-Schema des Spiels
komprimiert:
n < 0x80 -> n Bytes Rohdaten folgen (n = 1..127)
1LLL oooo oooooooo -> kopiere L+3 Bytes (3..10) ab o Bytes (1..4095) zurück
Aufruf: png2cor.py eingabe.png ausgabe.cor (benötigt Pillow)
The image is converted to 640x480 RGB and compressed with the game's LZ
scheme:
n < 0x80 -> n bytes of raw data follow (n = 1..127)
1LLL oooo oooooooo -> copy L+3 bytes (3..10) from o bytes (1..4095) back
Usage: png2cor.py input.png output.cor (requires Pillow)
"""
import struct
import sys
@@ -17,7 +17,7 @@ MAXOFF, MINLEN, MAXLEN = 0x0FFF, 3, 10
def pack(raw):
out = bytearray()
lit = bytearray()
last = {} # 3-Byte-Präfix -> Liste letzter Positionen
last = {} # 3-byte prefix -> list of recent positions
def flush():
for k in range(0, len(lit), 127):
@@ -65,4 +65,4 @@ if __name__ == '__main__':
img = Image.open(sys.argv[1]).convert('RGB').resize((640, 480), Image.LANCZOS)
data = pack(img.tobytes())
open(sys.argv[2], 'wb').write(data)
print(f'{sys.argv[2]}: {len(data)} Bytes')
print(f'{sys.argv[2]}: {len(data)} bytes')
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""STPC grob durchlaufen (nur Grenzen): Modelle, Animsets, Flag-0x100-Block, dann Strat-Code.
Nachgebaut aus FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
"""Rough STPC walk (boundaries only): models, animsets, flag-0x100 block, then strat code.
Reimplemented from FUN_0042ab50 / FUN_0041f770 / FUN_0041f8b0 (groove.exe)."""
import struct
@@ -52,11 +52,11 @@ def animsets(d, o, n):
def walk(d, wfpc):
n = u32(d, 0)
o, mdl = 4, []
for _ in range(n): # FUN_0041f770(&p,1,..) je Modell: Header direkt gefolgt von Daten
for _ in range(n): # FUN_0041f770(&p,1,..) per model: header directly followed by data
o, mm = models(d, o, 1)
mdl += mm
na = u32(d, o); o += 4
for _ in range(na): # FUN_0041f8b0(&p,1,..) je Animset
for _ in range(na): # FUN_0041f8b0(&p,1,..) per animset
o = animsets(d, o, 1)
extra = []
if wfpc & 0x100:
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tabelle Strat-Typ x Level (Instanzzahl) über alle Level-WADs.
"""Table strat type x level (instance count) across all level WADs.
strat_table.py wads_dir > table.md"""
import glob
import os
@@ -25,11 +25,11 @@ for w in wads:
for o, nm in strats.all_named(ch['STPC'], lo):
avail[nm].add(name)
short = [l[1:].replace('i0m', '/').replace('l', '').replace('m00', '/') for l in lv]
print('| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels |')
print('| Type | Level (MAP instances) | Σ | in code of n levels |')
print('|---|---|---|---|')
for nm in sorted(tab, key=lambda k: (-len(tab[k]), k)):
cells = ' '.join(f'{l[1:]}:{tab[nm][l]}' for l in lv if l in tab[nm])
base = nm.replace('(spawn)', '')
print(f'| {nm} | {cells} | {sum(tab[nm].values())} | {len(avail.get(base, ()))} |')
print()
print('Nur im Code (nie direkt in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
print('Only in code (never placed directly in MAP):', ', '.join(sorted(n for n in avail if n not in {k.replace("(spawn)", "") for k in tab})))
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Strat-Typen (Objekttypen) eines Level-WADs: Namen aus STPC-Strat-Code, MAP-Instanzen.
"""Strat types (object types) of a level WAD: names from the STPC strat code, MAP instances.
Strat-Code liegt am Ende von STPC (nach Modellen/Animsets). Bytecode = u32-Wörter
(low16 = Opcode, high16 = Kurzoperand). Ein Strat beginnt (fast immer) mit
... 0xd4, 0xea, <s32 rel> -> Name = C-String bei (Adresse nach rel) + rel
Strat code sits at the end of STPC (after models/animsets). Bytecode = u32 words
(low16 = opcode, high16 = short operand). A strat (almost always) starts with
... 0xd4, 0xea, <s32 rel> -> name = C string at (address after rel) + rel
strats.py level.wad [--all]
"""
import os
@@ -21,7 +21,7 @@ def cstr(d, o):
def strat_name(st, entry, lo):
"""Name eines MAP-Einstiegs: Spawn-Stub (b2 <abs> e0 ...) -> Ziel; sonst erstes d4/ea ab entry."""
"""Name of a MAP entry: spawn stub (b2 <abs> e0 ...) -> target; otherwise first d4/ea from entry."""
w = struct.unpack_from('<8I', st, entry)
for k in range(5):
if w[k] == 0xb2 and w[k + 2] == 0xe0 and lo <= w[k + 1] < len(st):
@@ -41,7 +41,7 @@ def strat_name(st, entry, lo):
def all_named(st, lo):
"""Alle d4/ea-Namensstellen im Code-Bereich (Wort-Raster ab lo)."""
"""All d4/ea name sites in the code region (word grid from lo)."""
out = []
for o in range(lo, len(st) - 12, 4):
a, b = struct.unpack_from('<II', st, o)
@@ -74,6 +74,6 @@ if __name__ == '__main__':
f'flags={s[11]:#x} extra={s[12]:#x}')
if '--all' in sys.argv:
used = {s[6] for v in types.values() for s in v}
print('-- alle benannten Strats im Code (Namens-Op-Adresse):')
print('-- all named strats in the code (name op address):')
for o, n in all_named(ch['STPC'], lo):
print(f' {o:#x} {n}')
+8 -8
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Textur-Kontaktabzug: alle (oder nur die von der Welt-Geometrie benutzten) Texturen eines
Level-WADs als Raster mit Index-Beschriftung -> zum Aussuchen von Texturen fuer levelbuild.py.
"""Texture contact sheet: all textures of a level WAD (or only those used by the world
geometry) as a grid labelled with their index -> for picking textures for levelbuild.py.
texsheet.py level.wad out.png [--all] [--size 64] [--cols 16] [--only 12,40,77]
Ohne --all: nur Texturen, die in TRAK (Welt-Kacheln) vorkommen, sortiert nach Haeufigkeit.
Beschriftung: Index, darunter Nutzung O=oben (Boden) / S=Seite (Wand) / U=unten, Groesse in px.
Flachfarben (Flag Bit0) werden als Farbfeld gezeigt ("F").
Without --all: only textures that occur in TRAK (world tiles), sorted by frequency.
Label: index, below it usage O=top (floor) / S=side (wall) / U=bottom, size in px.
Flat colours (flag bit 0) are shown as a colour swatch ("F").
"""
import argparse
import collections
@@ -43,7 +43,7 @@ def main():
ap.add_argument('--all', action='store_true')
ap.add_argument('--size', type=int, default=64)
ap.add_argument('--cols', type=int, default=16)
ap.add_argument('--only', help='kommagetrennte Indizes')
ap.add_argument('--only', help='comma-separated indices')
a = ap.parse_args()
ch = G.wad_read(a.wad)
pages, texs = G.parse_text(G.wad_get(ch, 'TEXT'))
@@ -64,7 +64,7 @@ def main():
for k, i in enumerate(idx):
t = texs[i]
x, y = (k % C) * cw + 4, (k // C) * chh + 2
# Schachbrett fuer Transparenz
# checkerboard for transparency
bg = Image.new('RGB', (S, S), (90, 90, 90))
bd = ImageDraw.Draw(bg)
for yy in range(0, S, 8):
@@ -82,7 +82,7 @@ def main():
d.text((x + S // 2 + 4, y + S + 1), tag, fill=(140, 220, 255))
d.text((x, y + S + 14), f'{w}x{h}', fill=(170, 170, 170))
sheet.save(a.out)
print(f'{a.out}: {len(idx)} Texturen, {sheet.size[0]}x{sheet.size[1]}')
print(f'{a.out}: {len(idx)} textures, {sheet.size[0]}x{sheet.size[1]}')
if __name__ == '__main__':
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""TEXT-Chunk -> PNG-Seiten (+ texinfo.txt) und zurück.
"""TEXT chunk -> PNG pages (+ texinfo.txt) and back.
text_export.py export level.wad outdir # pageNN_fXX.png, texinfo.txt
text_export.py import level.wad outdir neu.wad # geänderte PNGs zurückschreiben
text_export.py import level.wad outdir new.wad # write modified PNGs back
"""
import glob
import os
@@ -33,7 +33,7 @@ def main():
f.write('# idx flags page clut x0 x1 y0 y1\n')
for i, e in enumerate(T.tex_entries(t['tail'])):
f.write(f'{i} {e[0]:#06x} {e[1]} {e[2]:#04x} {e[3]} {e[4]} {e[5]} {e[6]}\n')
print(f'{len(t["pages"])} Seiten, {len(T.tex_entries(t["tail"]))} Texturen -> {out}')
print(f'{len(t["pages"])} pages, {len(T.tex_entries(t["tail"]))} textures -> {out}')
elif cmd == 'import':
for fn in glob.glob(f'{out}/page*.png'):
i = int(re.search(r'page(\d+)', fn).group(1))
@@ -41,13 +41,13 @@ def main():
img = np.asarray(Image.open(fn).convert('RGBA'))
assert img.shape[:2] == (p['h'], p['w']), fn
new = T.rgba_to_abgr1555(img)
# unveränderte Pixel exakt erhalten (Rundung 8->5 Bit verlustfrei rückwärts)
# keep unchanged pixels exactly (8->5 bit rounding is not lossless in reverse)
old = T.abgr1555_to_rgba(p['px'])
same = (old == img).all(-1)
p['px'] = np.where(same, p['px'], new).astype(np.uint16)
tc[1] = T.build(t)
wadtool.write(sys.argv[4], ch)
print('geschrieben:', sys.argv[4])
print('written:', sys.argv[4])
if __name__ == '__main__':
+16 -16
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""TEXT-Chunk (Texturen) von Level-WADs lesen/schreiben.
"""Read/write the TEXT chunk (textures) of level WADs.
Aufbau (aus groove.exe 0x4067b0):
Layout (from groove.exe 0x4067b0):
u32 nPal; nPal x { u32 id, u32 n, u16 col[n], u8 extra[n] }
u32 nPages; nPages x { u32 flags, u32 w, u32 h, Daten }
flags==0 : w*h Byte (8-Bit, Palette)
flags&0x80 : u32 Länge, RLE16 (s16 n: n>0 -> n Literal-u16, n<0 -> nächstes u16 (-n)-mal)
sonst : w*h u16 roh
u32 nTex; Rest = Textur-Einträge (8 Byte) + Anhang (siehe notes/textures.md)
u32 nPages; nPages x { u32 flags, u32 w, u32 h, data }
flags==0 : w*h bytes (8-bit, palette)
flags&0x80 : u32 length, RLE16 (s16 n: n>0 -> n literal u16, n<0 -> next u16 repeated -n times)
otherwise : w*h u16 raw
u32 nTex; rest = texture entries (8 bytes) + appendix (see notes/textures.md)
"""
import struct
import numpy as np
@@ -33,10 +33,10 @@ def rle_encode(px):
j = i
while j + 1 < N and px[j + 1] == px[i] and j - i < 32766:
j += 1
if j > i: # Lauf >= 2
if j > i: # run >= 2
out += [(-(j - i + 1)) & 0xffff, int(px[i])]; i = j + 1
continue
k = i # Literale bis zum nächsten Lauf
k = i # literals up to the next run
while k < N and k - i < 32767 and not (k + 1 < N and px[k + 1] == px[k]):
k += 1
out += [k - i] + [int(x) for x in px[i:k]]; i = k
@@ -85,9 +85,9 @@ def build(t, compress=True):
def abgr1555_to_rgba(px):
"""PS1-Format: Bits 0-4 R, 5-9 G, 10-14 B, Bit 15 = STP.
0x0000 = durchsichtig (Alpha 0). STP=1 -> Alpha 255, STP=0 (Pixel != 0) -> Alpha 128
(auf Seiten mit Blendmodus = halbtransparent)."""
"""PS1 format: bits 0-4 R, 5-9 G, 10-14 B, bit 15 = STP.
0x0000 = transparent (alpha 0). STP=1 -> alpha 255, STP=0 (pixel != 0) -> alpha 128
(semi-transparent on pages with a blend mode)."""
px = px.astype(np.uint32)
r = (px & 31) * 255 // 31
g = ((px >> 5) & 31) * 255 // 31
@@ -97,13 +97,13 @@ def abgr1555_to_rgba(px):
def rgba_to_abgr1555(img):
"""Umkehrung: Alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (oder RGB-Bild) -> STP=1."""
"""Inverse: alpha < 64 -> 0x0000, 64..191 -> STP=0, >= 192 (or RGB image) -> STP=1."""
img = np.asarray(img).astype(np.uint32)
v = ((img[..., 2] >> 3) << 10) | ((img[..., 1] >> 3) << 5) | (img[..., 0] >> 3)
if img.shape[-1] == 4:
a = img[..., 3]
v = np.where(a >= 192, v | 0x8000, v)
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # fast schwarz statt durchsichtig
v = np.where((a >= 64) & (a < 192) & (v == 0), 0x0421, v) # near-black instead of transparent
v = np.where(a < 64, 0, v)
else:
v = v | 0x8000
@@ -111,7 +111,7 @@ def rgba_to_abgr1555(img):
def tex_entries(tail):
"""Textur-Einträge (Faces referenzieren diese Indizes): u16 flags, u8 page, u8 clut(0xff),
u8 x0, u8 x1, u8 y0, u8 y1 (inklusive Kanten)."""
"""Texture entries (faces reference these indices): u16 flags, u8 page, u8 clut(0xff),
u8 x0, u8 x1, u8 y0, u8 y1 (edges inclusive)."""
n = struct.unpack_from('<I', tail, 0)[0]
return [struct.unpack_from('<H6B', tail, 4 + 8 * i) for i in range(n)]
+2 -1
View File
@@ -1,4 +1,5 @@
# label.py IN.png LABELS.json OUT.jpg "Titel" [breite] -> beschriftete JPG-Vorschau
# label.py IN.png LABELS.json OUT.jpg "Title" [width] -> labelled JPG preview
# (label names Start/Galerie/Gehege/Parkour/Ziel are the keys written by render_level.py)
import sys, json
from PIL import Image, ImageDraw, ImageFont
src, lj, out, title = sys.argv[1:5]
+14 -14
View File
@@ -1,5 +1,5 @@
# Blender 3.0 (headless): level.obj aus geom_export (v x y z r g b, vt, usemtl page_NN/flat_N) laden,
# in Spielkoordinaten (Blender x = Spiel-x, y = -Spiel-z, z = Spiel-y) rendern.
# Blender 3.0 (headless): load level.obj from geom_export (v x y z r g b, vt, usemtl page_NN/flat_N),
# render in game coordinates (Blender x = game x, y = -game z, z = game y).
# blender -b -P render_level.py -- OBJDIR OUTPREFIX [samples] [scale%]
import bpy, sys, os, json, math
from mathutils import Vector
@@ -50,10 +50,10 @@ def make_mat(name, d):
else:
kd = d.get('kd', (1, 1, 1))
mul.inputs[1].default_value = (*kd, 1)
if kd[2] > 0.5 and kd[0] < 0.3: # Meer
if kd[2] > 0.5 and kd[0] < 0.3: # sea
bsdf.inputs['Roughness'].default_value = 0.25
bsdf.inputs['Specular'].default_value = 0.5
# leicht selbstleuchtend, damit die eingebackene Beleuchtung (Vertexfarben) sichtbar bleibt
# slightly emissive so the baked lighting (vertex colours) stays visible
Lk.new(mul.outputs[0], bsdf.inputs['Base Color'])
Lk.new(mul.outputs[0], bsdf.inputs['Emission'])
bsdf.inputs['Emission Strength'].default_value = 0.25
@@ -69,7 +69,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
if ln.startswith('v '):
p = ln.split()
x, y, z = float(p[1]), float(p[2]), float(p[3])
V.append((x, z, y)) # Spiel (x, y, -z) -> Blender (x, -spiel_z, y) = (x, z_obj, y_obj)
V.append((x, z, y)) # game (x, y, -z) -> Blender (x, -game_z, y) = (x, z_obj, y_obj)
C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1))
elif ln.startswith('vt '):
p = ln.split(); VT.append((float(p[1]), float(p[2])))
@@ -81,7 +81,7 @@ for ln in open(os.path.join(objdir, 'level.obj')):
q = t.split('/')
vi.append(int(q[0]) - 1)
ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1)
faces.append((vi[::-1], ti[::-1], mat)) # Spiegelung -> Umlaufsinn umdrehen
faces.append((vi[::-1], ti[::-1], mat)) # mirroring -> reverse winding
matnames = sorted(set(f[2] for f in faces))
mesh = bpy.data.meshes.new('level')
@@ -103,7 +103,7 @@ ob = bpy.data.objects.new('level', mesh)
sc.collection.objects.link(ob)
# --------------------------------------------------------------- Objekt-Marker (Strats aus strats.json)
# --------------------------------------------------------------- object markers (strats from strats.json)
def flatmat(name, rgb, emit=0.6):
m = bpy.data.materials.new(name); m.use_nodes = True
b = m.node_tree.nodes['Principled BSDF']
@@ -143,7 +143,7 @@ if sj and os.path.exists(sj):
mat = RM[r]
o = bpy.context.active_object; o.data.materials.append(mat)
# --------------------------------------------------------------- Licht, Welt
# --------------------------------------------------------------- light, world
w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True
bg = w.node_tree.nodes['Background']
bg.inputs[0].default_value = (0.55, 0.72, 0.95, 1); bg.inputs[1].default_value = 0.9
@@ -151,7 +151,7 @@ sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 2.6; sun.angle = math.radi
so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
so.rotation_euler = (math.radians(42), 0, math.radians(-140))
# Cycles liefert mit dem Debian-Blender 3.0.1 nur Schwarz -> Eevee (braucht X: eigenes Xvfb, z. B. :7)
# Cycles renders only black with Debian Blender 3.0.1 -> Eevee (needs X: a separate Xvfb, e.g. :7)
sc.render.engine = 'BLENDER_EEVEE'
ee = sc.eevee
ee.taa_render_samples = samples
@@ -164,9 +164,9 @@ try:
sc.view_settings.view_transform = 'Filmic'
sc.view_settings.look = 'Medium High Contrast'
except TypeError:
pass # Debian-Blender ohne OCIO-Konfiguration: nur 'Standard'
pass # Debian Blender without OCIO config: only 'Standard'
# Bereiche (Spielkoordinaten x, z, y) fuer die Beschriftung
# areas (game coordinates x, z, y) for the labels; keys are the label text (German, as in the level)
LABELS = {'Start': (18, 15.5, 2.2), 'Galerie': (23.5, 26.5, 1.2), 'Gehege': (37.5, 25.4, 1.2),
'Parkour': (33.0, 15.5, 2.6), 'Ziel': (50.0, 15.5, 6.0)}
@@ -195,9 +195,9 @@ def shoot(name, loc, look, lens=35, ortho=None, res=(1600, 1000)):
views = os.environ.get('VIEWS', 'schraeg,oben').split(',')
if 'schraeg' in views: # von Suedwesten, schraeg oben
if 'schraeg' in views: # 'schraeg' = oblique: from the south-west, above
shoot('schraeg', (10, -50, 27), (30.5, -21.5, 0), lens=33)
if 'schraeg2' in views: # von Nordosten (hinter dem Ziel)
if 'schraeg2' in views: # from the north-east (behind the goal)
shoot('schraeg2', (60, 2, 22), (29, -21, 0), lens=30)
if 'oben' in views:
if 'oben' in views: # 'oben' = top view
shoot('oben', (31, -21.5, 80), (31, -21.4, 0), ortho=44)
+1 -1
View File
@@ -1,4 +1,4 @@
# Strat-Positionen eines Levelskripts als JSON (fuer Marker im Blender-Render)
# strat positions of a level script as JSON (for markers in the Blender render)
import sys, json, os
sys.path.insert(0, 'tools')
import levelbuild as LB
+8 -8
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Liest und schreibt Level-WADs von "Ein Königreich für ein Lama" (Argonaut).
"""Reads and writes level WADs of "Ein Königreich für ein Lama" (Argonaut).
Aufbau: u32 Gesamtlänge (ohne dieses Feld), dann Chunks:
4 Byte Kennung (rückwärts gespeichert, z. B. 'OFNI' = INFO), u32 Länge, Daten
bis zum Chunk 'END '.
Layout: u32 total length (excluding this field), then chunks:
4-byte tag (stored reversed, e.g. 'OFNI' = INFO), u32 length, data
up to the chunk 'END '.
wadtool.py list level.wad
wadtool.py name level.wad neu.wad "Neuer Levelname"
wadtool.py extract level.wad CHUNK datei.bin
wadtool.py replace level.wad neu.wad CHUNK datei.bin
wadtool.py name level.wad new.wad "New level name"
wadtool.py extract level.wad CHUNK file.bin
wadtool.py replace level.wad new.wad CHUNK file.bin
"""
import struct
import sys
@@ -18,7 +18,7 @@ def read(path):
d = open(path, 'rb').read()
total = struct.unpack_from('<I', d, 0)[0]
if total != len(d) - 4:
raise ValueError(f'{path}: Längenfeld {total} passt nicht zu {len(d) - 4}')
raise ValueError(f'{path}: length field {total} does not match {len(d) - 4}')
chunks, o = [], 4
while o + 8 <= len(d):
tag = d[o:o + 4][::-1].decode('latin1')