SA Model Viewer base

This commit is contained in:
jack
2026-08-29 20:31:31 +01:00
commit 4eab14d4d6
20 changed files with 57452 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
// RenderWare binary stream: every chunk is a 12 byte header followed by its
// body, and container chunks simply hold more chunks. That one rule covers
// both DFF and TXD.
export const RW = {
STRUCT: 0x01,
STRING: 0x02,
EXTENSION: 0x03,
TEXTURE: 0x06,
MATERIAL: 0x07,
MATLIST: 0x08,
FRAMELIST: 0x0e,
GEOMETRY: 0x0f,
CLUMP: 0x10,
ATOMIC: 0x14,
TEXNATIVE: 0x15,
TEXDICT: 0x16,
GEOMETRYLIST: 0x1a,
BINMESH: 0x50e,
SKIN: 0x116,
HANIM: 0x11e,
FRAME_NAME: 0x253f2fe,
};
// GTA:SA ships RenderWare 3.6.0.3, which packs to 0x1803FFFF.
export const SA_VERSION = 0x36003;
export function unpackVersion(libraryId) {
if (libraryId & 0xffff0000) {
return (((libraryId >> 14) & 0x3ff00) + 0x30000) | ((libraryId >> 16) & 0x3f);
}
return libraryId << 8;
}
export function versionString(version) {
// major.minor.revision.build - the build number lives in the low 6 bits.
return [(version >> 16) & 0xf, (version >> 12) & 0xf, (version >> 8) & 0xf, version & 0x3f].join('.');
}
export class Reader {
constructor(view, start, end) {
this.view = view;
this.pos = start;
this.end = end;
}
get remaining() {
return this.end - this.pos;
}
u8() {
return this.view.getUint8(this.pos++);
}
u16() {
const v = this.view.getUint16(this.pos, true);
this.pos += 2;
return v;
}
u32() {
const v = this.view.getUint32(this.pos, true);
this.pos += 4;
return v;
}
i32() {
const v = this.view.getInt32(this.pos, true);
this.pos += 4;
return v;
}
f32() {
const v = this.view.getFloat32(this.pos, true);
this.pos += 4;
return v;
}
skip(n) {
this.pos += n;
}
bytes(n) {
const v = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n);
this.pos += n;
return v;
}
// Fixed width, null padded name field.
fixedString(n) {
const raw = this.bytes(n);
let len = raw.indexOf(0);
if (len < 0) len = n;
return String.fromCharCode(...raw.subarray(0, len));
}
f32Array(n) {
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = this.f32();
return out;
}
}
export class Chunk {
constructor(view, offset, limit) {
this.view = view;
this.type = view.getUint32(offset, true);
this.size = view.getUint32(offset + 4, true);
this.version = unpackVersion(view.getUint32(offset + 8, true));
this.start = offset + 12;
this.end = Math.min(this.start + this.size, limit === undefined ? view.byteLength : limit);
}
reader() {
return new Reader(this.view, this.start, this.end);
}
*children() {
let offset = this.start;
// A 12 byte header has to fit before anything can be read.
while (offset + 12 <= this.end) {
const child = new Chunk(this.view, offset, this.end);
yield child;
const step = 12 + child.size;
if (step <= 0) break; // corrupt size, don't spin
offset += step;
}
}
find(type) {
for (const child of this.children()) {
if (child.type === type) return child;
}
return null;
}
findAll(type) {
const out = [];
for (const child of this.children()) {
if (child.type === type) out.push(child);
}
return out;
}
// Convenience for the STRUCT chunk that nearly every container starts with.
structReader() {
const struct = this.find(RW.STRUCT);
if (!struct) throw new Error(`chunk 0x${this.type.toString(16)} has no struct`);
return struct.reader();
}
}
export function readRoot(buffer, expectedType) {
if (buffer.byteLength < 12) throw new Error('file is too small to be a RenderWare stream');
const view = new DataView(buffer);
const root = new Chunk(view, 0);
if (expectedType !== undefined && root.type !== expectedType) {
throw new Error(
`expected chunk 0x${expectedType.toString(16)}, got 0x${root.type.toString(16)} ` +
'(wrong file type, or the file is compressed/encrypted)'
);
}
return root;
}