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:
1 parent
86fe26d786
commit
526f6072f6
36 files changed
+1553
-1544
No files matched your search
+10
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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__':
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user