idk shitload of stuff

This commit is contained in:
2026-06-13 18:18:17 +02:00
parent 48c499e356
commit 6bb9b009c4
24 changed files with 3221 additions and 119 deletions
+179
View File
@@ -0,0 +1,179 @@
//! Quake-`.map`-Parser (Standard-Format, wie TrenchBroom es mit dem
//! Generic-Game exportiert).
//!
//! Struktur: eine Datei ist eine Liste von Entities `{...}`. Eine Entity
//! hält Key/Value-Properties (`"key" "value"`) und null oder mehr Brushes
//! `{...}`. Ein Brush ist eine Liste von Faces; jede Face ist eine Ebene
//! aus drei Punkten plus Textur-Ausrichtung:
//!
//! ( x y z ) ( x y z ) ( x y z ) TEXTUR offX offY rot scaleX scaleY
//!
//! Hier wird nur geparst — Geometrie bleibt im Quake-Koordinatensystem
//! (Z-up, Brush = Schnitt der Halbräume); die Umrechnung ins Engine-System
//! und der Halbraum-Schnitt sind Sache des Konsumenten (render::brush).
use std::collections::HashMap;
use std::fs::read_to_string;
pub struct Map {
pub entities: Vec<Entity>,
}
#[derive(Default)]
pub struct Entity {
pub props: HashMap<String, String>,
pub brushes: Vec<Brush>,
}
impl Entity {
// Wird beim Entity-/Metadaten-Schritt gebraucht (worldspawn vs. Props,
// Signal-Bindung); bis dahin nur in Tests verwendet.
#[allow(dead_code)]
pub fn classname(&self) -> Option<&str> {
self.props.get("classname").map(String::as_str)
}
}
pub struct Brush {
pub faces: Vec<Face>,
}
/// Eine Brush-Face: Ebene aus drei Punkten (Quake-Reihenfolge, im
/// Uhrzeigersinn von vorn gesehen) plus Textur-Ausrichtung. Die
/// Ausrichtungsfelder werden erst vom Textur-Schritt gebraucht.
pub struct Face {
pub plane: [[f32; 3]; 3],
pub texture: String,
#[allow(dead_code)]
pub offset: [f32; 2],
#[allow(dead_code)]
pub rotation: f32,
#[allow(dead_code)]
pub scale: [f32; 2],
}
pub fn load(path: &str) -> Map {
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
parse(&src)
}
pub fn parse(src: &str) -> Map {
let mut entities = Vec::new();
let mut cur_entity: Option<Entity> = None;
let mut cur_brush: Option<Brush> = None;
for raw in src.lines() {
// Kommentar (`// …`) abschneiden, dann trimmen.
let line = match raw.split_once("//") {
Some((code, _)) => code.trim(),
None => raw.trim(),
};
if line.is_empty() { continue; }
match line {
"{" => {
// Erstes `{` öffnet eine Entity, ein weiteres einen Brush.
if cur_entity.is_none() {
cur_entity = Some(Entity::default());
} else {
cur_brush = Some(Brush { faces: Vec::new() });
}
}
"}" => {
if let Some(brush) = cur_brush.take() {
cur_entity.as_mut().unwrap().brushes.push(brush);
} else if let Some(entity) = cur_entity.take() {
entities.push(entity);
}
}
_ => {
if let Some(brush) = cur_brush.as_mut() {
if let Some(face) = parse_face(line) {
brush.faces.push(face);
}
} else if let Some(entity) = cur_entity.as_mut() {
if let Some((k, v)) = parse_property(line) {
entity.props.insert(k, v);
}
}
}
}
}
Map { entities }
}
/// `"key" "value"` — Wert darf leer sein.
fn parse_property(line: &str) -> Option<(String, String)> {
let mut it = line.split('"');
it.next()?; // vor dem ersten "
let key = it.next()?; // key
it.next()?; // zwischen den Paaren
let val = it.next()?; // value
Some((key.to_string(), val.to_string()))
}
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
fn parse_face(line: &str) -> Option<Face> {
let t: Vec<&str> = line.split_whitespace().collect();
let mut i = 0;
let mut plane = [[0.0f32; 3]; 3];
for p in 0..3 {
if *t.get(i)? != "(" { return None; }
for k in 0..3 {
plane[p][k] = t.get(i + 1 + k)?.parse().ok()?;
}
if *t.get(i + 4)? != ")" { return None; }
i += 5;
}
let texture = (*t.get(i)?).to_string();
let nums: Option<Vec<f32>> = t[i + 1..i + 6].iter().map(|s| s.parse().ok()).collect();
let n = nums?;
Some(Face {
plane,
texture,
offset: [n[0], n[1]],
rotation: n[2],
scale: [n[3], n[4]],
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
// entity 0
{
"classname" "worldspawn"
"wad" ""
// brush 0
{
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) placeholder 32 -32 0 1 1
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) placeholder -16 -32 0 1 1
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) placeholder -16 -32 0 1 1
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) placeholder -16 -32 0 1 1
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) placeholder -16 -32 0 1 1
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) placeholder 32 -32 0 1 1
}
}
"#;
#[test]
fn parses_entity_brush_and_face() {
let m = parse(SAMPLE);
assert_eq!(m.entities.len(), 1);
let e = &m.entities[0];
assert_eq!(e.classname(), Some("worldspawn"));
assert_eq!(e.props.get("wad").map(String::as_str), Some(""));
assert_eq!(e.brushes.len(), 1);
let b = &e.brushes[0];
assert_eq!(b.faces.len(), 6);
let f = &b.faces[0];
assert_eq!(f.texture, "placeholder");
assert_eq!(f.plane[0], [-48.0, -64.0, -48.0]);
assert_eq!(f.plane[2], [-48.0, -64.0, -47.0]);
assert_eq!(f.offset, [32.0, -32.0]);
assert_eq!(f.scale, [1.0, 1.0]);
}
}
+7
View File
@@ -15,10 +15,17 @@
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen).
//!
//! `map` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend
//! per Pull konsumiert. `map` ist zugleich der Anfang des headless
//! Datenmodells (Phase 2 des Renderer-Plans).
pub mod assets;
pub mod game;
pub mod ink;
pub mod kv;
pub mod map;
pub mod signals;
pub mod story_ctrl;
pub mod tga;
+150
View File
@@ -0,0 +1,150 @@
//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
//!
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
//! RGB-Umrechnung jedem Konsumenten einzeln überließ.) Konsumenten ziehen
//! ein `Image` und legen es ab, wie sie es brauchen: Renderer → GPU-Textur,
//! UI/Font → eigene Interpretation. Die UV-/V-Achsen-Konvention ist damit
//! Sache des Konsumenten beim Sampling, nicht des Loaders.
//!
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
//! Font-Maps. Paletten-TGAs gibt es nicht mehr (wds ist 15-bit True-Color).
pub struct Image {
pub width: u32,
pub height: u32,
/// `width * height * 4`, Ursprung oben-links.
pub rgba: Vec<u8>,
}
pub fn load(path: &str) -> Image {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("tga load {path}: {e}"));
decode(&bytes).unwrap_or_else(|e| panic!("tga decode {path}: {e}"))
}
pub fn decode(d: &[u8]) -> Result<Image, String> {
if d.len() < 18 { return Err("Header zu kurz".into()); }
let id_len = d[0] as usize;
let cmap_type = d[1];
let image_type = d[2];
let width = u16::from_le_bytes([d[12], d[13]]) as u32;
let height = u16::from_le_bytes([d[14], d[15]]) as u32;
let depth = d[16];
let descriptor = d[17];
if cmap_type != 0 {
return Err("Color-Map-TGAs nicht unterstützt (True-Color only)".into());
}
// Pixeldaten beginnen nach Header + ID-Feld (Color-Map ist leer).
let off = 18 + id_len;
let channels = match (image_type, depth) {
(2 | 10, 24) => 3,
(2 | 10, 32) => 4,
(3 | 11, 8) => 1,
(t, b) => return Err(format!("nicht unterstützt: Typ {t}, {b} bpp")),
};
let n = (width as usize) * (height as usize);
if n == 0 { return Err("Nullgröße".into()); }
// Rohpixel (channels Bytes je Pixel), bei RLE entpackt.
let raw_len = n * channels;
let raw: Vec<u8> = match image_type {
2 | 3 => d.get(off..off + raw_len).ok_or("Pixeldaten zu kurz")?.to_vec(),
10 | 11 => decode_rle(d.get(off..).ok_or("RLE-Daten fehlen")?, raw_len, channels)?,
_ => unreachable!(),
};
// → RGBA8. True-Color liegt im TGA als BGR(A) vor; Graustufen replizieren.
let mut rgba = vec![0u8; n * 4];
for i in 0..n {
let (r, g, b, a) = match channels {
1 => { let v = raw[i]; (v, v, v, 255) }
3 => (raw[i * 3 + 2], raw[i * 3 + 1], raw[i * 3], 255),
4 => (raw[i * 4 + 2], raw[i * 4 + 1], raw[i * 4], raw[i * 4 + 3]),
_ => unreachable!(),
};
let o = i * 4;
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
}
// Ursprung oben-links erzwingen. Descriptor-Bit 5 gesetzt = top-origin.
if (descriptor >> 5) & 1 == 0 {
flip_vertical(&mut rgba, width as usize, height as usize);
}
Ok(Image { width, height, rgba })
}
/// TGA-RLE: Pakete operieren auf ganzen Pixeln (`ch` Bytes).
fn decode_rle(src: &[u8], raw_len: usize, ch: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(raw_len);
let mut si = 0;
while out.len() < raw_len {
let header = *src.get(si).ok_or("RLE: Quelle zu kurz")?;
si += 1;
let count = (header & 0x7f) as usize + 1;
if header & 0x80 != 0 {
// RLE-Paket: ein Pixel count-mal.
let px = src.get(si..si + ch).ok_or("RLE: Pixel zu kurz")?;
si += ch;
for _ in 0..count { out.extend_from_slice(px); }
} else {
// Raw-Paket: count Pixel am Stück.
let bytes = count * ch;
let chunk = src.get(si..si + bytes).ok_or("RLE: Chunk zu kurz")?;
si += bytes;
out.extend_from_slice(chunk);
}
}
out.truncate(raw_len);
Ok(out)
}
fn flip_vertical(rgba: &mut [u8], w: usize, h: usize) {
let row = w * 4;
for y in 0..h / 2 {
let (a, b) = (y * row, (h - 1 - y) * row);
for x in 0..row { rgba.swap(a + x, b + x); }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bgr_to_rgba_uncompressed() {
// Minimal-TGA: Typ 2, 2×1, 24 bpp, bottom-origin (descriptor 0).
// Zwei Pixel BGR (10,20,30) und (40,50,60).
let mut d = vec![0u8; 18];
d[2] = 2; d[12] = 2; d[14] = 1; d[16] = 24;
d.extend_from_slice(&[10, 20, 30, 40, 50, 60]);
let img = decode(&d).unwrap();
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(&img.rgba[0..4], &[30, 20, 10, 255]);
assert_eq!(&img.rgba[4..8], &[60, 50, 40, 255]);
}
#[test]
fn rle_truecolor_expands() {
// Typ 10, 4×1, 24 bpp: ein RLE-Paket (4× BGR 1,2,3).
let mut d = vec![0u8; 18];
d[2] = 10; d[12] = 4; d[14] = 1; d[16] = 24;
d.push(0x80 | 3); // RLE, count = 4
d.extend_from_slice(&[1, 2, 3]); // ein Pixel
let img = decode(&d).unwrap();
assert_eq!(img.rgba.len(), 4 * 4);
assert!(img.rgba.chunks(4).all(|p| p == [3, 2, 1, 255]));
}
#[test]
fn decodes_real_placeholder() {
let bytes = std::fs::read("assets/textures/placeholder.tga").unwrap();
let img = decode(&bytes).unwrap();
assert_eq!((img.width, img.height), (256, 256));
assert_eq!(img.rgba.len(), 256 * 256 * 4);
assert!(img.rgba.chunks(4).all(|p| p[3] == 255)); // 24bpp → Alpha 255
}
}