From 50145dcd6b903f7afc96a570757fec8a35704b9f Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:52:13 +0200 Subject: [PATCH 01/49] Fix the Y-sort rendering - New shared helper dispel_core::map::types::tiled_object_sort_key(anchor_y, stack_index) = anchor_y + level*32 + 32 - GUI + CLI + legacy plot_objects: one interlaced item per stack tile, each sorting at its own screen band; entities now sandwich correctly between a tall building's lower and upper tiles - CLI keys unified to map-local space in both occlusion and full-map modes - Deleted dead plot_single_tiled_object - Remove dead code from map_editor --- .../src/components/map_render/render_tiles.rs | 55 +++++----- .../editors/map_editor/canvas/render_tiles.rs | 6 -- .../src/editors/map_editor/canvas/state.rs | 3 - src/map/render.rs | 101 +++++++++--------- src/map/types.rs | 51 +++++++++ 5 files changed, 131 insertions(+), 85 deletions(-) delete mode 100644 dispel-gui/src/editors/map_editor/canvas/render_tiles.rs delete mode 100644 dispel-gui/src/editors/map_editor/canvas/state.rs diff --git a/dispel-gui/src/components/map_render/render_tiles.rs b/dispel-gui/src/components/map_render/render_tiles.rs index c35a643f..7472d421 100644 --- a/dispel-gui/src/components/map_render/render_tiles.rs +++ b/dispel-gui/src/components/map_render/render_tiles.rs @@ -87,7 +87,7 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye let noy_f = noy as f32; enum Item { - TiledObject(usize), + TiledObject { obj: usize, level: usize }, Sprite(usize), Entity(usize), } @@ -95,9 +95,14 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye let mut items: Vec<(i32, i32, i32, Item)> = Vec::new(); if view.show_buildings { + // One item per stack tile: per-tile depth lets entities + // interleave with tall buildings. for (i, info) in map_data.tiled_infos.iter().enumerate() { - let pos = info.y + info.ids.len() as i32 * TILE_H as i32; - items.push((pos, 0, i as i32, Item::TiledObject(i))); + for level in 0..info.ids.len() { + let pos = + dispel_core::map::types::tiled_object_sort_key(info.y, level); + items.push((pos, 0, info.x, Item::TiledObject { obj: i, level })); + } } } @@ -121,31 +126,31 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye for (_, _, _, item) in &items { match item { - Item::TiledObject(obj_i) => { - let info = &map_data.tiled_infos[*obj_i]; - let base_x = (info.x as f32 + nox_f) * zoom + pan_x; - let base_y = (info.y as f32 + noy_f) * zoom + pan_y; + Item::TiledObject { obj, level } => { + let info = &map_data.tiled_infos[*obj]; + let Some(&btl_id) = info.ids.get(*level) else { + continue; + }; + if btl_id <= 0 { + continue; + } + let handle_id = btl_id.unsigned_abs() as i32; + let Some(handle) = self.state.btl_handles().get(&handle_id) else { + continue; + }; + let px = (info.x as f32 + nox_f) * zoom + pan_x; + let py = (info.y as f32 + noy_f) * zoom + + pan_y + + *level as f32 * TILE_H * zoom; let w = TILE_W * zoom; let h = TILE_H * zoom; - for (i, &btl_id) in info.ids.iter().enumerate() { - if btl_id <= 0 { - continue; - } - let handle_id = btl_id.unsigned_abs() as i32; - let Some(handle) = self.state.btl_handles().get(&handle_id) - else { - continue; - }; - let px = base_x; - let py = base_y + i as f32 * h; - if !is_visible(px, py, w, h, bounds) { - continue; - } - frame.draw_image( - Rectangle::new(Point::new(px, py), Size::new(w, h)), - CoreImage::new(handle.clone()), - ); + if !is_visible(px, py, w, h, bounds) { + continue; } + frame.draw_image( + Rectangle::new(Point::new(px, py), Size::new(w, h)), + CoreImage::new(handle.clone()), + ); } Item::Sprite(i) => { let spr = &self.state.internal_sprite_handles()[*i]; diff --git a/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs b/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs deleted file mode 100644 index 70f1f664..00000000 --- a/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs +++ /dev/null @@ -1,6 +0,0 @@ -// ── MapCanvasTilesLayer — delegates to shared GenericTilesLayer ─────────────── - -pub type MapCanvasTilesLayer<'a> = crate::components::map_render::GenericTilesLayer< - 'a, - crate::editors::map_editor::state::MapEditorState, ->; diff --git a/dispel-gui/src/editors/map_editor/canvas/state.rs b/dispel-gui/src/editors/map_editor/canvas/state.rs deleted file mode 100644 index f7c99a69..00000000 --- a/dispel-gui/src/editors/map_editor/canvas/state.rs +++ /dev/null @@ -1,3 +0,0 @@ -// ── Interaction state (re-exported from shared map_render module) ───────────── - -pub use crate::components::map_render::MapCanvasState; diff --git a/src/map/render.rs b/src/map/render.rs index 6e425ae8..b6ae4916 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -10,6 +10,7 @@ use byteorder::{LittleEndian, ReadBytesExt}; use super::types::{ Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo, convert_map_coords_to_image_coords, + tiled_object_sort_key, }; use super::model::MapModel; @@ -183,11 +184,9 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { > = HashMap::new(); let diagonal = data.model.tiled_map_width + data.model.tiled_map_height; - let noy = if occlusion { - 0 - } else { - data.model.map_non_occluded_start_y - }; + // All interlaced sort keys are compared in map-local pixel space + // (world Y minus the non-occluded origin), independent of output mode. + let noy = data.model.map_non_occluded_start_y; // Helper: entity sort position (matches GUI's entity_pos) let entity_pos = |tx: i32, ty: i32| -> i32 { @@ -195,7 +194,7 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { }; enum ItemKind { - TiledObject(usize), + TiledObject(usize, usize), Sprite(usize), Monster(usize), Npc(usize), @@ -204,20 +203,25 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { let mut items: Vec<(i32, i32, i32, ItemKind)> = Vec::new(); - // Buildings (type_order=0) + // Buildings (type_order=0) — one item per stack tile so entities + // interleave at per-tile depth (see tiled_object_sort_key). Keys are + // map-local, matching entity_pos below. if toggles.show_buildings { for (i, info) in data.tiled_infos.iter().enumerate() { - let pos = info.y + info.ids.len() as i32 * TILE_HEIGHT as i32 + noy; - items.push((pos, 0, i as i32, ItemKind::TiledObject(i))); + for level in 0..info.ids.len() { + let pos = tiled_object_sort_key(info.y, level); + items.push((pos, 0, info.x, ItemKind::TiledObject(i, level))); + } } } - // Internal sprites (type_order=1) + // Internal sprites (type_order=1) — key is map-local + // (sprite_bottom_right_y == sprite_y + height). if toggles.show_internal_sprites { for (i, block) in data.sprite_blocks.iter().enumerate() { if block.sprite_id < data.internal_sprites.len() { let h = data.internal_sprites[block.sprite_id].frame_infos[0].height; - items.push((block.sprite_y + h + noy, 1, 0, ItemKind::Sprite(i))); + items.push((block.sprite_y + h, 1, block.sprite_x, ItemKind::Sprite(i))); } } } @@ -245,18 +249,19 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { for (_, _, _, item) in &items { match item { - ItemKind::TiledObject(i) => { + ItemKind::TiledObject(i, level) => { let info = &data.tiled_infos[*i]; - for (j, &btl_id) in info.ids.iter().enumerate() { - if btl_id <= 0 { - continue; - } - let btl_tile_idx = btl_id.unsigned_abs() as usize; - if let Some(tile) = btl_tileset.get(btl_tile_idx) { - let x = info.x + offset_x; - let y = info.y + (j as i32 * TILE_HEIGHT as i32) + offset_y; - plot_tile(&mut imgbuf, tile.colors, x, y); - } + let Some(&btl_id) = info.ids.get(*level) else { + continue; + }; + if btl_id <= 0 { + continue; + } + let btl_tile_idx = btl_id.unsigned_abs() as usize; + if let Some(tile) = btl_tileset.get(btl_tile_idx) { + let x = info.x + offset_x; + let y = info.y + (*level as i32 * TILE_HEIGHT as i32) + offset_y; + plot_tile(&mut imgbuf, tile.colors, x, y); } } ItemKind::Sprite(i) => { @@ -447,7 +452,7 @@ pub fn plot_objects( ) -> Result<()> { enum Kind { Sprite(usize), - TiledObject(usize), + TiledObject { obj: usize, level: usize }, } struct Item { ground_y: i32, @@ -464,11 +469,14 @@ pub fn plot_objects( kind: Kind::Sprite(i), }); } + // Per-tile depth (see tiled_object_sort_key) — consistent with render_map. for (i, info) in params.tiled_info.iter().enumerate() { - items.push(Item { - ground_y: info.y + (info.ids.len() as i32 * TILE_HEIGHT as i32), - kind: Kind::TiledObject(i), - }); + for level in 0..info.ids.len() { + items.push(Item { + ground_y: tiled_object_sort_key(info.y, level), + kind: Kind::TiledObject { obj: i, level }, + }); + } } items.sort_by_key(|it| it.ground_y); @@ -482,35 +490,26 @@ pub fn plot_objects( params.offset_x, params.offset_y, )?, - Kind::TiledObject(i) => plot_single_tiled_object( - imgbuf, - ¶ms.tiled_info[i], - params.btl_tileset, - params.offset_x, - params.offset_y, - ), + Kind::TiledObject { obj, level } => { + let tiled_info = ¶ms.tiled_info[obj]; + let Some(&btl_id) = tiled_info.ids.get(level) else { + continue; + }; + if btl_id <= 0 { + continue; + } + let btl_tile_idx = btl_id.unsigned_abs() as usize; + if let Some(tile) = params.btl_tileset.get(btl_tile_idx) { + let x = tiled_info.x + params.offset_x; + let y = tiled_info.y + (level as i32 * TILE_HEIGHT as i32) + params.offset_y; + plot_tile(imgbuf, tile.colors, x, y); + } + } } } Ok(()) } -fn plot_single_tiled_object( - imgbuf: &mut ImageBuffer, Vec>, - tiled_info: &TiledObjectInfo, - btl_tileset: &[Tile], - offset_x: i32, - offset_y: i32, -) { - for (i, btl_id) in tiled_info.ids.iter().enumerate() { - let btl_tile_idx = btl_id.unsigned_abs() as usize; - if let Some(tile) = btl_tileset.get(btl_tile_idx) { - let x = tiled_info.x + offset_x; - let y = tiled_info.y + (i as i32 * TILE_HEIGHT as i32) + offset_y; - plot_tile(imgbuf, tile.colors, x, y); - } - } -} - fn plot_single_sprite( imgbuf: &mut ImageBuffer, Vec>, reader: &mut BufReader, diff --git a/src/map/types.rs b/src/map/types.rs index d6eae18d..ffd16f23 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -15,6 +15,20 @@ pub fn convert_map_coords_to_image_coords(x: i32, y: i32, map_diagonal_tiles: i3 (start_x, start_y) } +/// Per-tile Y-sort key for one tile of a stacked BTL building (interlaced pass). +/// +/// Each tile of a building stack sorts at its own screen band so entities can +/// interleave correctly with tall structures. Sorting the whole stack by +/// its visual bottom edge (`y + count * tile_h`) instead makes tall buildings +/// wrongly occlude entities that stand behind or beside their base. +/// +/// `anchor_y` is the map-local pixel Y of the stack anchor (the `y` field of +/// [`TiledObjectInfo`]); `stack_index` is the 0-based position within `ids`. +pub fn tiled_object_sort_key(anchor_y: i32, stack_index: usize) -> i32 { + let tile_h = TILE_HEIGHT_HALF * 2; // 32 + anchor_y + stack_index as i32 * tile_h + tile_h +} + /// An event trigger attached to a tile on the map. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] pub struct EventBlock { @@ -41,3 +55,40 @@ pub struct TiledObjectInfo { pub x: i32, pub y: i32, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tiled_object_sort_key_per_tile_bands() { + // Each stack tile sorts one tile-height (32px) after the previous. + assert_eq!(tiled_object_sort_key(100, 0), 132); + assert_eq!(tiled_object_sort_key(100, 1), 164); + assert_eq!(tiled_object_sort_key(100, 5), 292); + } + + /// Regression: an NPC standing behind a tall building must be drawn after + /// the building's base-level tiles. The old whole-stack key + /// (`y + count * 32`) sorted the entire building after him, letting it + /// paint over his sprite even though it stands behind him. + #[test] + fn test_npc_behind_tall_building_interleaves_with_stack() { + let npc_feet = 560; // map-local bottom of the NPC's tile diamond + + // Building anchored at y=512, 4 tiles tall (visual bottom edge = 640). + let keys: Vec = (0..4) + .map(|level| tiled_object_sort_key(512, level)) + .collect(); + assert_eq!(keys, [544, 576, 608, 640]); + + // Only tiles whose band extends below the NPC's feet sort after him; + // the base tile sorts before, so he renders in front of it. + assert!(keys[0] < npc_feet, "base tile must draw before the NPC"); + assert_eq!( + keys.iter().filter(|&&k| k > npc_feet).count(), + 3, + "only the 3 upper-stack tiles may draw after the NPC" + ); + } +} From e6cf3e3555d9e1e4ac0077b612efaa2c0372c572 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:36:16 +0200 Subject: [PATCH 02/49] Fix rendering order (pope on chair fix). --- .../src/components/map_render/render_tiles.rs | 20 ++++++- .../src/components/map_render/traits.rs | 57 +++++++++++++++++++ src/map/render.rs | 15 +++-- src/map/types.rs | 7 +++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/dispel-gui/src/components/map_render/render_tiles.rs b/dispel-gui/src/components/map_render/render_tiles.rs index 7472d421..fa432894 100644 --- a/dispel-gui/src/components/map_render/render_tiles.rs +++ b/dispel-gui/src/components/map_render/render_tiles.rs @@ -108,17 +108,31 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye if view.show_internal_sprites { for (i, spr) in self.state.internal_sprite_handles().iter().enumerate() { - items.push((spr.sort_y, 1, 0, Item::Sprite(i))); + // Half-tile window: props lose near-ties to + // characters sitting/standing on them (see + // internal_sprite_sort_key). + items.push(( + dispel_core::map::types::internal_sprite_sort_key(spr.sort_y), + 1, + spr.x as i32, + Item::Sprite(i), + )); } } - // External entity sort key + // External entity sort key — kind rung breaks ties between + // entities sharing a tile (NPC must draw over an Extra). for i in 0..self.state.entity_count() { if let Some(ed) = self.state.entity_data(i) { if !ed.visible { continue; } - items.push((ed.sort_key, 2, ed.tile_x, Item::Entity(i))); + items.push(( + ed.sort_key, + ed.kind.type_order(), + ed.tile_x, + Item::Entity(i), + )); } } diff --git a/dispel-gui/src/components/map_render/traits.rs b/dispel-gui/src/components/map_render/traits.rs index e9336246..3eaee6cd 100644 --- a/dispel-gui/src/components/map_render/traits.rs +++ b/dispel-gui/src/components/map_render/traits.rs @@ -11,6 +11,63 @@ pub enum EntityKind { DrawItem, } +impl EntityKind { + /// Category tiebreaker for the interlaced Y-sort (lower draws first). + /// + /// Rungs: buildings (0) < internal sprites (1) < extras/props (2) < + /// draw items (3) < monsters (4) < NPCs (5). + /// + /// When entities share a tile their Y keys and tile X are equal, so this + /// rung decides: e.g. an NPC sitting on a chair draws over it, and a drop + /// item lying on a prop draws over the prop. + pub fn type_order(self) -> i32 { + match self { + EntityKind::Extra => 2, + EntityKind::DrawItem => 3, + EntityKind::Monster => 4, + EntityKind::Npc => 5, + } + } +} + +#[cfg(test)] +mod tests { + use super::EntityKind; + + /// Regression: an NPC placed on an Extra (sitting on a chair) must render + /// over it. Both tie on Y key and tile X, so the kind rung decides. + #[test] + fn test_entity_type_order_npc_draws_over_extra() { + assert!(EntityKind::Npc.type_order() > EntityKind::Extra.type_order()); + } + + /// Regression: a drop item lying on a prop must render over the prop. + #[test] + fn test_entity_type_order_draw_item_draws_over_extra() { + assert!(EntityKind::DrawItem.type_order() > EntityKind::Extra.type_order()); + } + + #[test] + fn test_entity_type_order_ladder_is_total() { + let order = [ + EntityKind::Extra, + EntityKind::DrawItem, + EntityKind::Monster, + EntityKind::Npc, + ]; + for pair in order.windows(2) { + assert!( + pair[0].type_order() < pair[1].type_order(), + "{:?} must draw before {:?}", + pair[0], + pair[1] + ); + } + // Entities stay above buildings (0) and internal sprites (1). + assert!(order[0].type_order() > 1); + } +} + pub struct EntityRenderData<'a> { pub tile_x: i32, pub tile_y: i32, diff --git a/src/map/render.rs b/src/map/render.rs index b6ae4916..6480b23f 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -10,7 +10,7 @@ use byteorder::{LittleEndian, ReadBytesExt}; use super::types::{ Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo, convert_map_coords_to_image_coords, - tiled_object_sort_key, + internal_sprite_sort_key, tiled_object_sort_key, }; use super::model::MapModel; @@ -216,26 +216,29 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { } // Internal sprites (type_order=1) — key is map-local - // (sprite_bottom_right_y == sprite_y + height). + // (sprite_bottom_right_y == sprite_y + height), minus the half-tile + // window so characters sitting on props draw over them. if toggles.show_internal_sprites { for (i, block) in data.sprite_blocks.iter().enumerate() { if block.sprite_id < data.internal_sprites.len() { let h = data.internal_sprites[block.sprite_id].frame_infos[0].height; - items.push((block.sprite_y + h, 1, block.sprite_x, ItemKind::Sprite(i))); + let pos = internal_sprite_sort_key(block.sprite_y + h); + items.push((pos, 1, block.sprite_x, ItemKind::Sprite(i))); } } } - // External entities (type_order=2) + // External entities (type_order rungs shared with the GUI interlaced + // pass: Extra=2 < DrawItem=3 < Monster=4 < Npc=5). if let Some(ref ext) = external { if toggles.show_monsters { for (i, m) in ext.monsters.iter().enumerate() { - items.push((entity_pos(m.x, m.y), 2, m.x, ItemKind::Monster(i))); + items.push((entity_pos(m.x, m.y), 4, m.x, ItemKind::Monster(i))); } } if toggles.show_npcs { for (i, n) in ext.npcs.iter().enumerate() { - items.push((entity_pos(n.x, n.y), 2, n.x, ItemKind::Npc(i))); + items.push((entity_pos(n.x, n.y), 5, n.x, ItemKind::Npc(i))); } } if toggles.show_objects { diff --git a/src/map/types.rs b/src/map/types.rs index ffd16f23..eb14a10b 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -29,6 +29,13 @@ pub fn tiled_object_sort_key(anchor_y: i32, stack_index: usize) -> i32 { anchor_y + stack_index as i32 * tile_h + tile_h } +/// Y-sort key for an internal map sprite (chair, throne, statue…) in the +/// interlaced pass: the file's `sprite_bottom_right_y` minus a half-tile +/// window. +pub fn internal_sprite_sort_key(bottom_right_y: i32) -> i32 { + bottom_right_y - TILE_HEIGHT_HALF +} + /// An event trigger attached to a tile on the map. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] pub struct EventBlock { From 9175c07d15ea48ae7b51f30ab21171c8505de1ab Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:49:55 +0200 Subject: [PATCH 03/49] Fix the y-sort render for the walls; recon the collision/access block --- .../src/components/map_render/render_tiles.rs | 68 ++-- .../src/components/map_render/view_state.rs | 2 + .../map_editor/canvas/render_overlays.rs | 71 +++- .../src/editors/map_editor/canvas/tests.rs | 1 + dispel-gui/src/editors/map_editor/message.rs | 1 + .../src/editors/map_editor/update/mod.rs | 1 + dispel-gui/src/editors/map_editor/view/mod.rs | 7 + docs/files/Map/Map.map.md | 320 +++++++++++------- src/map/mod.rs | 10 +- src/map/reader.rs | 129 ++++++- src/map/render.rs | 79 ++--- src/map/types.rs | 55 +-- src/map/writer.rs | 9 +- 13 files changed, 489 insertions(+), 264 deletions(-) diff --git a/dispel-gui/src/components/map_render/render_tiles.rs b/dispel-gui/src/components/map_render/render_tiles.rs index fa432894..ac273ed9 100644 --- a/dispel-gui/src/components/map_render/render_tiles.rs +++ b/dispel-gui/src/components/map_render/render_tiles.rs @@ -87,7 +87,7 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye let noy_f = noy as f32; enum Item { - TiledObject { obj: usize, level: usize }, + TiledObject(usize), Sprite(usize), Entity(usize), } @@ -95,22 +95,20 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye let mut items: Vec<(i32, i32, i32, Item)> = Vec::new(); if view.show_buildings { - // One item per stack tile: per-tile depth lets entities - // interleave with tall buildings. + // Buildings draw as single units ordered by their stack bottom. for (i, info) in map_data.tiled_infos.iter().enumerate() { - for level in 0..info.ids.len() { - let pos = - dispel_core::map::types::tiled_object_sort_key(info.y, level); - items.push((pos, 0, info.x, Item::TiledObject { obj: i, level })); - } + let pos = dispel_core::map::types::tiled_object_sort_key( + info.y, + info.ids.len(), + ); + items.push((pos, 0, info.x, Item::TiledObject(i))); } } if view.show_internal_sprites { for (i, spr) in self.state.internal_sprite_handles().iter().enumerate() { - // Half-tile window: props lose near-ties to - // characters sitting/standing on them (see - // internal_sprite_sort_key). + // Half-tile window: props lose near-ties to characters sitting/standing + // on them (see internal_sprite_sort_key). items.push(( dispel_core::map::types::internal_sprite_sort_key(spr.sort_y), 1, @@ -120,8 +118,8 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye } } - // External entity sort key — kind rung breaks ties between - // entities sharing a tile (NPC must draw over an Extra). + // External entity sort key — kind rung breaks ties between entities sharing a + // tile (NPC must draw over an Extra). for i in 0..self.state.entity_count() { if let Some(ed) = self.state.entity_data(i) { if !ed.visible { @@ -140,31 +138,31 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye for (_, _, _, item) in &items { match item { - Item::TiledObject { obj, level } => { - let info = &map_data.tiled_infos[*obj]; - let Some(&btl_id) = info.ids.get(*level) else { - continue; - }; - if btl_id <= 0 { - continue; - } - let handle_id = btl_id.unsigned_abs() as i32; - let Some(handle) = self.state.btl_handles().get(&handle_id) else { - continue; - }; - let px = (info.x as f32 + nox_f) * zoom + pan_x; - let py = (info.y as f32 + noy_f) * zoom - + pan_y - + *level as f32 * TILE_H * zoom; + Item::TiledObject(i) => { + let info = &map_data.tiled_infos[*i]; + let base_x = (info.x as f32 + nox_f) * zoom + pan_x; + let base_y = (info.y as f32 + noy_f) * zoom + pan_y; let w = TILE_W * zoom; let h = TILE_H * zoom; - if !is_visible(px, py, w, h, bounds) { - continue; + for (j, &btl_id) in info.ids.iter().enumerate() { + if btl_id <= 0 { + continue; + } + let handle_id = btl_id.unsigned_abs() as i32; + let Some(handle) = self.state.btl_handles().get(&handle_id) + else { + continue; + }; + let px = base_x; + let py = base_y + j as f32 * h; + if !is_visible(px, py, w, h, bounds) { + continue; + } + frame.draw_image( + Rectangle::new(Point::new(px, py), Size::new(w, h)), + CoreImage::new(handle.clone()), + ); } - frame.draw_image( - Rectangle::new(Point::new(px, py), Size::new(w, h)), - CoreImage::new(handle.clone()), - ); } Item::Sprite(i) => { let spr = &self.state.internal_sprite_handles()[*i]; diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs index bcec022f..44d2938f 100644 --- a/dispel-gui/src/components/map_render/view_state.rs +++ b/dispel-gui/src/components/map_render/view_state.rs @@ -23,6 +23,7 @@ pub struct MapViewState { pub show_npc_waypoints: bool, pub show_objects: bool, pub show_draw_items: bool, + pub show_object_ids: bool, /// Last known cursor position in canvas-local pixel coordinates. /// Set to f32::NAN when the cursor is not over the canvas. pub cursor_canvas_x: f32, @@ -69,6 +70,7 @@ impl Default for MapViewState { show_npc_waypoints: false, show_objects: true, show_draw_items: true, + show_object_ids: false, cursor_canvas_x: f32::NAN, cursor_canvas_y: f32::NAN, last_canvas_w: 1200.0, diff --git a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs index 584827e9..4a562766 100644 --- a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs +++ b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs @@ -131,6 +131,50 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { } } + // Object ID overlay — cyan diamond + label for non-zero object slots + if self.state.view.show_object_ids { + for (&(tx, ty), &obj_id) in &map_data.object_ids { + if obj_id == 0 { + continue; + } + let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); + let w = TILE_W * zoom; + let h = TILE_H * zoom; + if !is_visible(px, py, w, h, bounds) { + continue; + } + let cx = px + w * 0.5; + let cy = py + h * 0.5; + let dx = w * 0.5; + let dy = h * 0.5; + frame.fill( + &canvas::Path::new(|b| { + b.move_to(Point::new(cx, cy - dy)); + b.line_to(Point::new(cx + dx, cy)); + b.line_to(Point::new(cx, cy + dy)); + b.line_to(Point::new(cx - dx, cy)); + b.close(); + }), + Color::from_rgba(0.1, 0.7, 0.8, 0.3), + ); + let label_size = (10.0 * zoom).max(6.0); + frame.fill_text(CanvasText { + content: obj_id.to_string(), + position: Point::new(cx, cy - 10.0 * zoom), + color: Color::WHITE, + size: iced::Pixels(label_size), + font: Font::MONOSPACE, + align_x: TextAlignment::Center, + align_y: alignment::Vertical::Bottom, + shaping: iced::widget::text::Shaping::Basic, + line_height: iced::widget::text::LineHeight::default(), + max_width: f32::INFINITY, + ellipsis: iced::widget::text::Ellipsis::None, + wrapping: iced::widget::text::Wrapping::None, + }); + } + } + // Draw items overlay (coloured diamond + item_id label) if self.state.view.show_draw_items { for di in &self.state.data.draw_items { @@ -433,8 +477,31 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { ) }); let label = format!("X: {} Y: {}", tile_x, tile_y); + // Build a compact tile-context readout from map data. + let context_label = { + let mut parts = Vec::new(); + if let Some(>l) = map_data.gtl_tiles.get(&(tile_x, tile_y)) { + parts.push(format!("gtl {}", gtl)); + } + if map_data + .collisions + .get(&(tile_x, tile_y)) + .copied() + .unwrap_or(false) + { + parts.push("blocked".into()); + } + if let Some(&obj) = map_data.object_ids.get(&(tile_x, tile_y)) { + parts.push(format!("obj {}", obj)); + } + if parts.is_empty() { + label.clone() + } else { + format!("{} · {}", label, parts.join(" · ")) + } + }; cursor_frame.fill_text(CanvasText { - content: label.clone(), + content: context_label.clone(), position: Point::new(11.5, 11.5), color: Color::from_rgba(0.0, 0.0, 0.0, 0.75), size: iced::Pixels(13.0), @@ -448,7 +515,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { wrapping: iced::widget::text::Wrapping::None, }); cursor_frame.fill_text(CanvasText { - content: label, + content: context_label, position: Point::new(10.0, 10.0), color: Color::WHITE, size: iced::Pixels(13.0), diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index 392e0eab..5bc815f6 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -56,6 +56,7 @@ fn make_state( btl_tiles: HashMap::new(), collisions, events, + object_ids: HashMap::new(), tiled_infos: vec![], internal_sprites: vec![], sprite_blocks: vec![], diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index b9dcbf18..2389b809 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -235,6 +235,7 @@ pub enum MapLayer { NpcWaypoints, Objects, DrawItems, + ObjectIds, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index dc197ab6..2b73b292 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -196,6 +196,7 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { } MapLayer::Objects => state.view.show_objects = !state.view.show_objects, MapLayer::DrawItems => state.view.show_draw_items = !state.view.show_draw_items, + MapLayer::ObjectIds => state.view.show_object_ids = !state.view.show_object_ids, } // Tile canvas renders entities and tile layers; overlay renders // collisions and events — clear both caches. diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index 254a001b..a04aab9f 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -209,6 +209,13 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::DrawItems, Some(state.data.draw_items.len()) ), + layer_toggle( + "Obj IDs", + state.view.show_object_ids, + tab_id, + MapLayer::ObjectIds, + None + ), ] .spacing(12) .padding([6, 16]) diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index 651b4a24..43b49f01 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -1,164 +1,230 @@ -# cat1.map - Dispel Game Map File Format +# DISPEL® Map File Format (`.map`) -## File Information -- **Location**: `Map/*.map` files -- **Format**: Binary (Little-Endian) -- **Coordinate System**: Isometric -- **Tile Size**: 62×32 pixels (diamond) +All game maps — overworld (`map1.map`…), catacombs (`cat1.map`…, `catp.map`), +and dungeons (`dun01.map`…, `final.map`) — share this binary format. A `.map` +file contains everything needed to render a map: dimensions, embedded sprites +(thrones, statues, decor), building stacks, and three per-tile grids (events, +ground tiles, occlusion). -## File Structure +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. -### Header (12 bytes) -- `width_in_chunks`: i32 - Map width in 25-tile chunks -- `height_in_chunks`: i32 - Map height in 25-tile chunks -- `border_count`: i32 - Border chunk count (always 2) +## Quick Facts + +| Property | Value | +|---|---| +| Location | `Map/*.map` | +| Endianness | Little-endian throughout | +| Grid unit | Tile, stored row-major (`y` outer, `x` inner) | +| Source tile | 32×32 px, RGB565 (2048 bytes) | +| Rendered tile | 62×32 px isometric diamond | +| Chunk | 25×25 tiles (dimensions are stored in chunks) | + +## File Layout -### Blocks (in order) +Blocks appear strictly in this order: -#### First Block (variable size) -- `count`: i32 - Record count -- `data`: (count − 1) × 8 bytes - `count − 1` records, each 2 × i32: `value1`, `value2` +``` +┌─────────────────────────────────────────────┐ +│ Header 12 bytes │ +│ First block variable │ skipped by our parser +│ Second block variable │ kept: referenced by Access-Ref +│ Sprite block variable │ embedded sprite sequences +│ Sprite Info block variable │ where to place them +│ Tiled Objects block variable │ buildings (BTL tile stacks) +│ Event block width×height×4 │ +│ Tile & Access block width×height×4 │ +│ Access-Ref block ("roof") width×height×4 │ +└─────────────────────────────────────────────┘ +``` -Skipping `(count-1)*8` lands exactly on the second block's size field. -Records of 8 bytes as a 20-byte record array (map object offset `+0x14c`): +The last three blocks are dense grids of exactly `width_tiles × height_tiles` +entries each, so a reader can seek to them from the end of the file. + +--- + +## Block Reference + +### Header (12 bytes) -| offset | value | source | +| Field | Type | Meaning | |---|---|---| -| `+0x00` | `index = i + 1` | computed (linked-list-style index) | -| `+0x04` | `0` | computed | -| `+0x08` | `value1` | read from file (4 bytes) | -| `+0x0c` | `0` | computed | -| `+0x10` | `value2` | read from file (4 bytes) | - -This code is seems to be not used. - -#### Second Block (variable size) -- `size`: i32 -- `data`: size × 2 bytes - byte pairs, read by the game into map object offset `+0x1ac` - -The game very likely skips those bytes. - -#### Sprite Block -- `sprite_count`: i32 -- For each sprite: - - `image_stamp`: i32 - - `metadata`: 264 bytes - - `sequence_info`: variable - - `pixel_data`: variable - -#### Sprite Info Block -- `placement_count`: i32 -- For each placement: - - `sprite_id`: i32 - - `position_data`: variable - - `frame_count`: i32 - -#### Tiled Objects Block -- `bundle_count`: i32 -- For each bundle: - - `metadata`: 264 bytes - - `coordinates`: (x,y) i32 each - - `tile_stack_ids`: variable - - `building_definition`: variable - -#### Event Block (near end of file) -- For each tile (width × height): - - `event_id`: i16 - - `unknown`: i16 - -#### Tile & Access Block -- For each tile (width × height): - - `gtl_tile_id`: i32 - - `collision_flag`: i32 - -#### Roof Tile Block (optional) -- For each tile (width × height): - - `btl_tile_id`: i16 - - `flags`: i16 +| `width_in_chunks` | i32 | Map width in 25-tile chunks | +| `height_in_chunks` | i32 | Map height in 25-tile chunks | +| `border_count` | i32 | Border chunk count (always 2) | -## Coordinate System -- **Chunk-based**: 1 chunk = 25×25 tiles -- **Isometric coordinates**: (x,y) tile positions -- **Tile size**: 32×32 pixels -- **Offsets**: - - `TILE_HORIZONTAL_OFFSET_HALF` = 32 - - `TILE_HEIGHT_HALF` = 16 - - `TILE_WIDTH_HALF` = 16 +### First Block *(unused)* -## File Size Calculation ``` -Total size = header + blocks + (width × height × (2+4+2)) + optional roof data +count: i32 +records: (count − 1) × 8 bytes -- each record: value1: i32, value2: i32 ``` -## Related Files -- `*.gtl` - Ground tileset files -- `*.btl` - Building/roof tileset files -- `AllMap.ini` - Map metadata and associations +Skipping `(count − 1) × 8` lands exactly on the next block's size field, +verified against the cat1/cat3/dun01/map1/catp fixtures. The pairs appear +unused by our tools; they are kept here for format completeness only. -## Map Files -- **Main maps**: `map1.map`, `map2.map`, `map3.map` -- **Catacombs**: `cat1.map`, `cat2.map`, `cat3.map`, `catp.map` -- **Dungeons**: `dun01.map` through `dun25.map`, `final.map` +### Second Block *(u16 lookup table)* -## Implementation -- **Rust Module**: `src/map/mod.rs` -- **Parser**: `read_map_data` function -- **Renderer**: `render_map` function -- **Database**: `import_map_to_database` function +``` +size: i32 +data: size × 2 bytes -- table of u16 entries +``` -## Example Usage +Not dead data: the Access-Ref block indexes into this table, and each +entry's low byte acts as a boolean flag consumed by occlusion/access checks. +All non-zero ids observed in shipped maps fall inside the table bounds. + +### Sprite Block *(embedded sprites)* -### Render a map to PNG: -```bash -cargo run -- map render \ - --map "fixtures/Dispel/Map/cat1.map" \ - --btl "fixtures/Dispel/Map/cat1.btl" \ - --gtl "fixtures/Dispel/Map/cat1.gtl" \ - --output cat1.png +``` +sprite_count: i32 +for each sprite: + image_stamp: i32 -- 6 or 9 + metadata: 264 bytes + sequence_info: variable -- frames, timing (see src/sprite.rs) + pixel_data: variable -- length depends on image_stamp: + 6 → 1904 bytes, 9 → 2996 bytes ``` -### Extract sprites from a map: -```bash -cargo run -- map sprites "fixtures/Dispel/Map/cat1.map" +Any other `image_stamp` is a parse error. Sprites here are thrones, statues, +decor — anything placed by pixel coordinate rather than tile grid. + +### Sprite Info Block *(placements)* + +``` +placement_count: i32 +for each placement: + sprite_id: i32 -- index into the Sprite Block + unknown: 2 × i32 + bottom_right_x: i32 -- Y-sort anchor (see below) + bottom_right_y: i32 + x: i32 -- top-left placement, map-local pixels + y: i32 + frame_skip: (frame_count − 1) × 6 × 4 bytes ``` -### Import to database: -```bash -cargo run -- map import "fixtures/Dispel/Map/cat1.map" +`bottom_right_y` equals `y + sprite_height` and serves as the depth key for +interlaced rendering. + +### Tiled Objects Block *(buildings)* + +``` +bundle_count: i32 +number1: i32 -- always observed as 1 +for each bundle: + metadata: 264 bytes + control: 4 × i32 -- expected pattern: 8, 0, 1, 0 + v1..v4: 4 × i32 -- unknown + x: i32 -- stack anchor, map-local pixels + y: i32 + v7, v8: 2 × i32 -- unknown + c1, c2, c3: 3 × i32 -- counts + tile_ids: c3 × i16 -- BTL tile stack, top → bottom + unknown_a: 84 bytes + unknown_b: (c1 + c2 + c3) × 4 bytes + -- see tiled_objects_block() in reader.rs ``` -## Coordinate Conversion -The `convert_map_coords_to_image_coords` function handles the isometric coordinate system conversion for rendering. +Each bundle is one building: its `tile_ids` stack is drawn downward from the +anchor, one 62×32 diamond per entry. Negative ids occur and are skipped when +drawing. -## Sprite Handling -Sprites are stored as sequences with metadata including: -- Frame information -- Animation timing -- Pixel data -- Placement coordinates +### Event Block -## Event System -Each tile can have an associated event ID that triggers in-game events when the player interacts with that location. +One record per tile, row-major: -## Extractor +``` +event_id: i16 -- low 14 bits hold the id (ids < 70 are + considered valid; resolvable through the + Map.ini / AllMap.ini tables) +unknown: i16 -- parameters/flags, semantics TBD +``` -An extractor is available in `src/map/mod.rs` to parse this file format. +### Tile & Access Block -### How to Run +One packed u32 per tile, row-major (bit layout verified against shipped +map data): -```bash -# Extract map sprites -cargo run -- map sprites "fixtures/Dispel/Map/cat1.map" +| Bits | Meaning | +|---|---| +| 0 | Collision flag (tile blocked) | +| 1–9 | Object slot id (0–511); non-zero marks an interactive object | +| 10–24 | GTL ground-tile index — `(word >> 10) & 0x7FFF` | +| 25–31 | Unused (always 0 in shipped maps) | + +The index points straight into the `.gtl` pixel data: the renderer blits from +`gtl_base + index × 2048` (one 32×32 RGB565 tile). Across observed maps the +maximum index matches the `.gtl` tile capacity minus one. + +Bits 0–9 together form one access field that can be rewritten without +touching the tile index. Shipped maps only ever use bit 0; bits 1–9 are +written at runtime. + +### Access-Ref Block *(a.k.a. "roof" block)* + +One record per tile, row-major: + +``` +ref_id: i16 -- bits 0–14: index into the Second Block table +flag: i16 -- rarely non-zero (a handful of border tiles) +``` + +Despite the historical name, this grid drives **occlusion/access**, not +visuals — roof pixels come from the Tiled Objects block. Each referenced +table entry's low byte is a boolean flag used by occlusion checks. + +--- + +## Coordinate System + +Tiles form an isometric diamond grid. Tile `(x, y)` renders at: + +``` +pixel_x = (x + y) × 32 +pixel_y = (y − x) × 16 + (map_diagonal / 2) × 16 +``` + +where `map_diagonal = tiled_width + tiled_height`. Moving `+1` in `x` steps +up-left on screen (−32, −16); moving `+1` in `y` steps down-right (+32, +16). + +Sprite and building placements inside the file are **map-local**: subtract +`map_non_occluded_start_x/y` (computed in `src/map/model.rs`) to convert to +world pixels. + +## File Size Calculation + +``` +fixed_tail = width_tiles × height_tiles × 12 (Event 4 + Tile&Access 4 + Access-Ref 4) +total = header + variable blocks + fixed_tail +``` -# Render map to PNG +## Related Files + +| File | Role | +|---|---| +| `*.gtl` | Ground tileset (raw 32×32 RGB565 tiles, no header) | +| `*.btl` | Building/roof tileset (same raw format) | +| `AllMap.ini` / `Map.ini` | Map metadata, per-map monster/NPC/extra ref files | +| `Mon*/Npc*/Ext*.ref` | External entities placed on the map | + +## Implementation & Usage + +- Parser: `read_map_data()` in `src/map/mod.rs`, block readers in `src/map/reader.rs` +- Renderer: `render_map()` in `src/map/render.rs`; GUI canvas in + `dispel-gui/src/components/map_render/` + +```bash +# Render a map to PNG cargo run -- map render \ --map "fixtures/Dispel/Map/cat1.map" \ --btl "fixtures/Dispel/Map/cat1.btl" \ --gtl "fixtures/Dispel/Map/cat1.gtl" \ --output cat1.png -# Import map to database -cargo run -- map to-db \ - --database "database.sqlite" \ - --map "fixtures/Dispel/Map/cat1.map" +# Extract embedded sprites +cargo run -- map sprites "fixtures/Dispel/Map/cat1.map" + +# Import map data to SQLite +cargo run -- map to-db --database "database.sqlite" --map "fixtures/Dispel/Map/cat1.map" ``` diff --git a/src/map/mod.rs b/src/map/mod.rs index c591541c..7adff114 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -138,6 +138,7 @@ pub struct MapData { pub btl_tiles: HashMap, pub collisions: HashMap, pub events: HashMap, + pub object_ids: HashMap, pub tiled_infos: Vec, pub internal_sprites: Vec, pub sprite_blocks: Vec, @@ -152,6 +153,7 @@ pub struct MapDataJson { pub btl_tiles: Vec, pub collisions: Vec, pub events: Vec, + pub object_ids: Vec, pub tiled_objects: Vec, pub sprites: Vec, pub internal_sprites: Vec, @@ -264,6 +266,11 @@ impl MapData { event_id: event.event_id, }) .collect(), + object_ids: self + .object_ids + .iter() + .map(|(&(x, y), &tile_id)| TileEntryJson { x, y, tile_id }) + .collect(), tiled_objects: self .tiled_infos .iter() @@ -378,7 +385,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { reader.seek(SeekFrom::End(skip))?; let events = read_events_block(reader, tiled_map_width, tiled_map_height)?; - let (gtl_tiles, collisions) = + let (gtl_tiles, collisions, object_ids) = read_tiles_and_access_block(reader, tiled_map_width, tiled_map_height)?; let mut btl_tiles = HashMap::new(); @@ -404,6 +411,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { btl_tiles, collisions, events, + object_ids, tiled_infos, internal_sprites, sprite_blocks, diff --git a/src/map/reader.rs b/src/map/reader.rs index a8063922..35bda4a0 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -7,9 +7,12 @@ /// 4. Sprite block – internal embedded sprites (sequence headers) /// 5. Sprite info block – placement records for embedded sprites /// 6. Tiled objects block – building/object tile stacks -/// 7. Event block – per-tile event trigger IDs (read from end of file) -/// 8. Tile & access block – GTL tile IDs + collision flags -/// 9. Roof block – BTL tile IDs (optional, only if data remains) +/// 7. Event block – per-tile packed u32 (low 14 bits: event/transition id) +/// 8. Tile & access block – per-tile packed u32: +/// bits 0–9 = access field (bit 0 collision, bits 1–9 object slot id), +/// bits 10–24 = GTL ground-tile index +/// 9. Access-ref block ("roof") – per-tile packed u32 indexing the second +/// block's u16 table (low 15 bits = table id) use byteorder::{LittleEndian, ReadBytesExt}; use std::collections::HashMap; use std::fs::File; @@ -173,7 +176,12 @@ pub fn tiled_objects_block(reader: &mut BufReader) -> Result> 10) & 0x7FFF. +// The renderer blits from gtl_base + index * 0x800 +// (2048 B = one 32×32 RGB565 tile in the .gtl file). +// bits 25–31 – unused (always 0 in shipped maps) +// +// The game's `setaccess` script command rewrites exactly bits 0–9, +// preserving the tile index — the game calls this whole field "access". +// Shipped maps only use bit 0; bits 1–9 are set at runtime. // -------------------------------------------------------------------------- +/// Parsed result of `read_tiles_and_access_block`: (gtl_tiles, collisions, object_ids). +type TileAccessResult = ( + HashMap, + HashMap, + HashMap, +); + pub fn read_tiles_and_access_block( reader: &mut BufReader, tiled_map_width: i32, tiled_map_height: i32, -) -> Result<(HashMap, HashMap)> { +) -> Result { let mut gtl_tiles = HashMap::new(); let mut collisions = HashMap::new(); + let mut object_ids = HashMap::new(); for y in 0..tiled_map_height { for x in 0..tiled_map_width { let coords: Coords = (x, y); let value = reader.read_i32::()?; - let gtl_tile_id = value >> 10; + let gtl_tile_id = (value >> 10) & 0x7FFF; let collision = (value & 0x1) == 1; + let object_id = (value >> 1) & 0x1FF; gtl_tiles.insert(coords, gtl_tile_id); collisions.insert(coords, collision); + if object_id != 0 { + object_ids.insert(coords, object_id); + } } } - Ok((gtl_tiles, collisions)) + Ok((gtl_tiles, collisions, object_ids)) } // -------------------------------------------------------------------------- -// Roof tile block – BTL tile IDs for building roofs +// Access-ref block ("roof") – one packed u32 per tile +// +// Bits 0–14 index the second block's u16 table (the table skipped by +// `second_block`); the entry's low byte is a boolean consumed by the game's +// tile-access/occlusion checks (HIDEACCESS / SHOWACCESS debug commands). +// Bits 15+ are flags (rare — e.g. 7 border tiles in cat1.map). Roof visuals +// themselves come from the tiled-objects block, not from this grid. // -------------------------------------------------------------------------- pub fn read_roof_tiles( @@ -253,3 +292,75 @@ pub fn read_roof_tiles( } Ok(btl_tiles) } + +#[cfg(test)] +mod tests { + + #[test] + fn test_tile_access_round_trip_synthetic_word() { + // Encode: gtl=1234, object_id=5, blocked=true + let gtl_tile_id: i32 = 1234; + let object_id: i32 = 5; + let blocked = true; + let value = ((gtl_tile_id & 0x7FFF) << 10) | ((object_id & 0x1FF) << 1) | (blocked as i32); + + // Decode + let decoded_gtl = (value >> 10) & 0x7FFF; + let decoded_blocked = (value & 0x1) == 1; + let decoded_object = ((value >> 1) & 0x1FF) as i32; + + assert_eq!(decoded_gtl, 1234, "GTL tile id mismatch"); + assert_eq!(decoded_object, 5, "Object id mismatch"); + assert!(decoded_blocked, "Blocked flag mismatch"); + + // Re-encode + let repacked = ((decoded_gtl & 0x7FFF) << 10) + | ((decoded_object & 0x1FF) << 1) + | (decoded_blocked as i32); + assert_eq!(repacked, value, "Repacked value differs from original"); + } + + #[test] + fn test_tile_access_round_trip_zero_object() { + // Shipped file case: object_id=0, high bits 0 + let gtl_tile_id: i32 = 42; + let object_id: i32 = 0; + let blocked = false; + let value = ((gtl_tile_id & 0x7FFF) << 10) | ((object_id & 0x1FF) << 1) | (blocked as i32); + + let decoded_gtl = (value >> 10) & 0x7FFF; + let decoded_blocked = (value & 0x1) == 1; + let decoded_object = ((value >> 1) & 0x1FF) as i32; + + assert_eq!(decoded_gtl, 42); + assert_eq!(decoded_object, 0); + assert!(!decoded_blocked); + + let repacked = ((decoded_gtl & 0x7FFF) << 10) + | ((decoded_object & 0x1FF) << 1) + | (decoded_blocked as i32); + assert_eq!(repacked, value); + } + + #[test] + fn test_tile_access_round_trip_max_object_and_gtl() { + // Max values: gtl=0x7FFF (32767), object_id=0x1FF (511), blocked=true + let gtl_tile_id: i32 = 0x7FFF; + let object_id: i32 = 0x1FF; + let blocked = true; + let value = ((gtl_tile_id & 0x7FFF) << 10) | ((object_id & 0x1FF) << 1) | (blocked as i32); + + let decoded_gtl = (value >> 10) & 0x7FFF; + let decoded_blocked = (value & 0x1) == 1; + let decoded_object = ((value >> 1) & 0x1FF) as i32; + + assert_eq!(decoded_gtl, 0x7FFF); + assert_eq!(decoded_object, 0x1FF); + assert!(decoded_blocked); + + let repacked = ((decoded_gtl & 0x7FFF) << 10) + | ((decoded_object & 0x1FF) << 1) + | (decoded_blocked as i32); + assert_eq!(repacked, value); + } +} diff --git a/src/map/render.rs b/src/map/render.rs index 6480b23f..a3a6b29d 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -175,8 +175,7 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { // ── Pass 2: Interleaved objects + entities ─────────────────────────── // All depth-relevant items (buildings, internal sprites, monsters, NPCs, // extras) are collected into one list, sorted by Y-depth with type - // tiebreaker, then rendered together — matching the DispelTools - // IInterlacedOrderObject / IInterlacedOrderObjectComparer approach. + // tiebreaker, then rendered together. { let mut sprite_cache: HashMap< PathBuf, @@ -194,7 +193,7 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { }; enum ItemKind { - TiledObject(usize, usize), + TiledObject(usize), Sprite(usize), Monster(usize), Npc(usize), @@ -203,15 +202,12 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { let mut items: Vec<(i32, i32, i32, ItemKind)> = Vec::new(); - // Buildings (type_order=0) — one item per stack tile so entities - // interleave at per-tile depth (see tiled_object_sort_key). Keys are - // map-local, matching entity_pos below. + // Buildings (type_order=0) — single units ordered by their stack bottom. + // Keys are map-local, matching entity_pos below. if toggles.show_buildings { for (i, info) in data.tiled_infos.iter().enumerate() { - for level in 0..info.ids.len() { - let pos = tiled_object_sort_key(info.y, level); - items.push((pos, 0, info.x, ItemKind::TiledObject(i, level))); - } + let pos = tiled_object_sort_key(info.y, info.ids.len()); + items.push((pos, 0, info.x, ItemKind::TiledObject(i))); } } @@ -252,19 +248,18 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { for (_, _, _, item) in &items { match item { - ItemKind::TiledObject(i, level) => { + ItemKind::TiledObject(i) => { let info = &data.tiled_infos[*i]; - let Some(&btl_id) = info.ids.get(*level) else { - continue; - }; - if btl_id <= 0 { - continue; - } - let btl_tile_idx = btl_id.unsigned_abs() as usize; - if let Some(tile) = btl_tileset.get(btl_tile_idx) { - let x = info.x + offset_x; - let y = info.y + (*level as i32 * TILE_HEIGHT as i32) + offset_y; - plot_tile(&mut imgbuf, tile.colors, x, y); + for (j, &btl_id) in info.ids.iter().enumerate() { + if btl_id <= 0 { + continue; + } + let btl_tile_idx = btl_id.unsigned_abs() as usize; + if let Some(tile) = btl_tileset.get(btl_tile_idx) { + let x = info.x + offset_x; + let y = info.y + (j as i32 * TILE_HEIGHT as i32) + offset_y; + plot_tile(&mut imgbuf, tile.colors, x, y); + } } } ItemKind::Sprite(i) => { @@ -455,7 +450,7 @@ pub fn plot_objects( ) -> Result<()> { enum Kind { Sprite(usize), - TiledObject { obj: usize, level: usize }, + TiledObject(usize), } struct Item { ground_y: i32, @@ -472,14 +467,13 @@ pub fn plot_objects( kind: Kind::Sprite(i), }); } - // Per-tile depth (see tiled_object_sort_key) — consistent with render_map. + // Whole-stack bottom key (see tiled_object_sort_key) — consistent with + // render_map. for (i, info) in params.tiled_info.iter().enumerate() { - for level in 0..info.ids.len() { - items.push(Item { - ground_y: tiled_object_sort_key(info.y, level), - kind: Kind::TiledObject { obj: i, level }, - }); - } + items.push(Item { + ground_y: tiled_object_sort_key(info.y, info.ids.len()), + kind: Kind::TiledObject(i), + }); } items.sort_by_key(|it| it.ground_y); @@ -493,19 +487,18 @@ pub fn plot_objects( params.offset_x, params.offset_y, )?, - Kind::TiledObject { obj, level } => { - let tiled_info = ¶ms.tiled_info[obj]; - let Some(&btl_id) = tiled_info.ids.get(level) else { - continue; - }; - if btl_id <= 0 { - continue; - } - let btl_tile_idx = btl_id.unsigned_abs() as usize; - if let Some(tile) = params.btl_tileset.get(btl_tile_idx) { - let x = tiled_info.x + params.offset_x; - let y = tiled_info.y + (level as i32 * TILE_HEIGHT as i32) + params.offset_y; - plot_tile(imgbuf, tile.colors, x, y); + Kind::TiledObject(i) => { + let tiled_info = ¶ms.tiled_info[i]; + for (j, &btl_id) in tiled_info.ids.iter().enumerate() { + if btl_id <= 0 { + continue; + } + let btl_tile_idx = btl_id.unsigned_abs() as usize; + if let Some(tile) = params.btl_tileset.get(btl_tile_idx) { + let x = tiled_info.x + params.offset_x; + let y = tiled_info.y + (j as i32 * TILE_HEIGHT as i32) + params.offset_y; + plot_tile(imgbuf, tile.colors, x, y); + } } } } diff --git a/src/map/types.rs b/src/map/types.rs index eb14a10b..0b77e030 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -15,18 +15,18 @@ pub fn convert_map_coords_to_image_coords(x: i32, y: i32, map_diagonal_tiles: i3 (start_x, start_y) } -/// Per-tile Y-sort key for one tile of a stacked BTL building (interlaced pass). +/// Y-sort key for a stacked BTL building in the interlaced pass: the visual +/// bottom edge of the whole stack (`y + count * tile_h`). /// -/// Each tile of a building stack sorts at its own screen band so entities can -/// interleave correctly with tall structures. Sorting the whole stack by -/// its visual bottom edge (`y + count * tile_h`) instead makes tall buildings -/// wrongly occlude entities that stand behind or beside their base. +/// The building draws as a single unit ordered by its stack bottom. +/// Per-tile interleaving was tried and rejected: a wall's lower tiles then +/// slice through objects standing in front of the structure. /// /// `anchor_y` is the map-local pixel Y of the stack anchor (the `y` field of -/// [`TiledObjectInfo`]); `stack_index` is the 0-based position within `ids`. -pub fn tiled_object_sort_key(anchor_y: i32, stack_index: usize) -> i32 { +/// [`TiledObjectInfo`]); `stack_size` is `ids.len()`. +pub fn tiled_object_sort_key(anchor_y: i32, stack_size: usize) -> i32 { let tile_h = TILE_HEIGHT_HALF * 2; // 32 - anchor_y + stack_index as i32 * tile_h + tile_h + anchor_y + stack_size as i32 * tile_h } /// Y-sort key for an internal map sprite (chair, throne, statue…) in the @@ -61,41 +61,4 @@ pub struct TiledObjectInfo { pub ids: Vec, pub x: i32, pub y: i32, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tiled_object_sort_key_per_tile_bands() { - // Each stack tile sorts one tile-height (32px) after the previous. - assert_eq!(tiled_object_sort_key(100, 0), 132); - assert_eq!(tiled_object_sort_key(100, 1), 164); - assert_eq!(tiled_object_sort_key(100, 5), 292); - } - - /// Regression: an NPC standing behind a tall building must be drawn after - /// the building's base-level tiles. The old whole-stack key - /// (`y + count * 32`) sorted the entire building after him, letting it - /// paint over his sprite even though it stands behind him. - #[test] - fn test_npc_behind_tall_building_interleaves_with_stack() { - let npc_feet = 560; // map-local bottom of the NPC's tile diamond - - // Building anchored at y=512, 4 tiles tall (visual bottom edge = 640). - let keys: Vec = (0..4) - .map(|level| tiled_object_sort_key(512, level)) - .collect(); - assert_eq!(keys, [544, 576, 608, 640]); - - // Only tiles whose band extends below the NPC's feet sort after him; - // the base tile sorts before, so he renders in front of it. - assert!(keys[0] < npc_feet, "base tile must draw before the NPC"); - assert_eq!( - keys.iter().filter(|&&k| k > npc_feet).count(), - 3, - "only the 3 upper-stack tiles may draw after the NPC" - ); - } -} +} \ No newline at end of file diff --git a/src/map/writer.rs b/src/map/writer.rs index 603ca56e..01ab2679 100644 --- a/src/map/writer.rs +++ b/src/map/writer.rs @@ -53,7 +53,9 @@ pub fn write_map_to_path(path: &Path, data: &MapData) -> io::Result<()> { let gtl_id = data.gtl_tiles.get(&coords).copied().unwrap_or(0); let collision = data.collisions.get(&coords).copied().unwrap_or(false); - let packed = (gtl_id << 10) | (if collision { 1 } else { 0 }); + let object_id = data.object_ids.get(&coords).copied().unwrap_or(0); + let packed = + ((gtl_id & 0x7FFF) << 10) | ((object_id & 0x1FF) << 1) | (collision as i32); bytes[base..base + 4].copy_from_slice(&(packed as i32).to_le_bytes()); } } @@ -120,6 +122,10 @@ mod tests { "Collisions differ" ); assert_eq!(original.btl_tiles, reloaded.btl_tiles, "BTL tiles differ"); + assert_eq!( + original.object_ids, reloaded.object_ids, + "Object IDs differ" + ); for y in 0..h { for x in 0..w { let o_ev = original.events.get(&(x, y)); @@ -189,6 +195,7 @@ mod tests { btl_tiles: HashMap::new(), collisions: HashMap::new(), events: HashMap::new(), + object_ids: HashMap::new(), tiled_infos: Vec::new(), internal_sprites: Vec::new(), sprite_blocks: Vec::new(), From cda39b72811d02ad18e443d87732fc9f6b51ea5a Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:33:54 +0200 Subject: [PATCH 04/49] Object ID painter --- .../src/editors/map_editor/canvas/hit_test.rs | 36 +++- .../src/editors/map_editor/canvas/tests.rs | 95 ++++++++++ dispel-gui/src/editors/map_editor/message.rs | 4 + dispel-gui/src/editors/map_editor/state.rs | 3 + .../src/editors/map_editor/update/entity.rs | 165 ++++++++++++++++++ .../src/editors/map_editor/update/mod.rs | 40 +++++ .../src/editors/map_editor/view/inspector.rs | 22 +++ dispel-gui/src/editors/map_editor/view/mod.rs | 19 +- 8 files changed, 379 insertions(+), 5 deletions(-) diff --git a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs index bb00fb37..dd2248e4 100644 --- a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs +++ b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs @@ -107,19 +107,45 @@ fn find_hovered_event_tile(state: &MapEditorState, cx: f32, cy: f32) -> Option<( Some((tile_x, tile_y)) } -/// Find what's under the cursor, with priority: entity > collision tile > event tile. +/// Find the object-id tile under the cursor (if any). Returns `(tx, ty)`. +fn find_hovered_object_id_tile(state: &MapEditorState, cx: f32, cy: f32) -> Option<(i32, i32)> { + let map_handle = state.map_data()?; + let model = &map_handle.0.model; + let diagonal = model.tiled_map_width + model.tiled_map_height; + + let (tile_x, tile_y) = screen_to_tile( + cx, + cy, + diagonal, + state.view.pan_x, + state.view.pan_y, + state.view.zoom, + model.tiled_map_width, + model.tiled_map_height, + )?; + + Some((tile_x, tile_y)) +} + +/// Find what's under the cursor, with priority: entity > object-id tile > collision tile > event tile. pub fn find_hovered_element(state: &MapEditorState, cx: f32, cy: f32) -> Option { // 1. Try entities (existing logic) if let Some(entity) = find_hovered_entity_impl(state, cx, cy) { return Some(entity); } - // 2. Try collision tiles (only when collision layer is visible) + // 2. Try object-id tiles (only when object-ids layer is visible) + if state.view.show_object_ids + && let Some((tx, ty)) = find_hovered_object_id_tile(state, cx, cy) + { + return Some(SelectedEntity::ObjectIdTile(tx, ty)); + } + // 3. Try collision tiles (only when collision layer is visible) if state.view.show_collisions && let Some((tx, ty)) = find_hovered_collision_tile(state, cx, cy) { return Some(SelectedEntity::CollisionTile(tx, ty)); } - // 3. Try event tiles (only when event layer is visible) + // 4. Try event tiles (only when event layer is visible) if state.view.show_events && let Some((tx, ty)) = find_hovered_event_tile(state, cx, cy) { @@ -138,7 +164,9 @@ pub fn entity_tile(sel: SelectedEntity, state: &MapEditorState) -> Option<(i32, }), SelectedEntity::Extra(i) => state.data.extra_refs.get(i).map(|e| (e.map_x, e.map_y)), SelectedEntity::DrawItem(i) => state.data.draw_items.get(i).map(|d| (d.x_coord, d.y_coord)), - SelectedEntity::CollisionTile(tx, ty) | SelectedEntity::EventTile(tx, ty) => Some((tx, ty)), + SelectedEntity::CollisionTile(tx, ty) + | SelectedEntity::EventTile(tx, ty) + | SelectedEntity::ObjectIdTile(tx, ty) => Some((tx, ty)), } } diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index 5bc815f6..5f1391e3 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -820,3 +820,98 @@ fn test_diamond_path_creates_valid_path() { // Canvas Path is opaque — just ensure it doesn't panic and has the right type. let _ = path; // (lint guard) } + +// ── ObjectIdTile hit-testing ────────────────────────────────────────────────── + +#[test] +fn test_hovered_element_object_id_tile_when_visible() { + let mut state = make_state( + 50, + 50, + vec![], + vec![], + vec![], + vec![], + HashMap::new(), + HashMap::new(), + ); + state.view.show_object_ids = true; + // Insert object_id into the loaded MapData. + if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { + Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 42); + } + + let (cx, cy) = tile_centre_canvas(&state, 10, 10); + assert_eq!( + find_hovered_element(&state, cx, cy), + Some(SelectedEntity::ObjectIdTile(10, 10)), + ); +} + +#[test] +fn test_hovered_element_object_id_tile_not_when_hidden() { + let mut state = make_state( + 50, + 50, + vec![], + vec![], + vec![], + vec![], + HashMap::new(), + HashMap::new(), + ); + state.view.show_object_ids = false; + if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { + Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 42); + } + + let (cx, cy) = tile_centre_canvas(&state, 10, 10); + // When show_object_ids is false, should return None (no other layer visible). + assert_eq!(find_hovered_element(&state, cx, cy), None); +} + +#[test] +fn test_hovered_element_object_id_tile_has_priority_over_collision() { + let mut collisions = HashMap::new(); + collisions.insert((10, 10), true); + let mut state = make_state( + 50, + 50, + vec![], + vec![], + vec![], + vec![], + collisions, + HashMap::new(), + ); + state.view.show_object_ids = true; + state.view.show_collisions = true; + if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { + Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 7); + } + + let (cx, cy) = tile_centre_canvas(&state, 10, 10); + // Object IDs have priority over collisions. + assert_eq!( + find_hovered_element(&state, cx, cy), + Some(SelectedEntity::ObjectIdTile(10, 10)), + ); +} + +#[test] +fn test_entity_tile_object_id_tile() { + let state = make_state( + 50, + 50, + vec![], + vec![], + vec![], + vec![], + HashMap::new(), + HashMap::new(), + ); + assert_eq!( + entity_tile(SelectedEntity::ObjectIdTile(5, 9), &state), + Some((5, 9)), + ); +} diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 2389b809..3a10c950 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -206,6 +206,8 @@ pub enum MapEditorMessage { ExportTmx(usize), /// Async TMX export completed. Ok carries the output path; Err the error. TmxExportComplete(usize, Result), + /// Set the object-id brush value for painting (1–511). + SetObjectBrush(usize, i32), } /// Which entity or tile is currently selected in the map editor inspector. @@ -220,6 +222,8 @@ pub enum SelectedEntity { CollisionTile(i32, i32), /// An event tile coordinate — carries (tx, ty). EventTile(i32, i32), + /// An object-id tile coordinate — carries (tx, ty). + ObjectIdTile(i32, i32), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/dispel-gui/src/editors/map_editor/state.rs b/dispel-gui/src/editors/map_editor/state.rs index c7feb86a..5d935fbd 100644 --- a/dispel-gui/src/editors/map_editor/state.rs +++ b/dispel-gui/src/editors/map_editor/state.rs @@ -172,6 +172,8 @@ pub struct MapDataState { pub status_msg: Option, /// Sprite export dialog state (None = dialog closed). pub sprite_export_dialog: Option, + /// Object-id brush value for painting (1–511, default 1). + pub object_brush: i32, } impl Default for MapDataState { @@ -205,6 +207,7 @@ impl Default for MapDataState { is_exporting: false, status_msg: None, sprite_export_dialog: None, + object_brush: 1, } } } diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 39f91b24..8e70cb07 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -50,6 +50,7 @@ pub fn field_changed( .map(|e| e.event_id.to_string()) .unwrap_or_default(), SelectedEntity::CollisionTile(_, _) => String::new(), + SelectedEntity::ObjectIdTile(_, _) => String::new(), }; // Apply the change; track whether the entity was actually found/mutated // so we don't push an undo action for a no-op (out-of-bounds index). @@ -105,6 +106,9 @@ pub fn field_changed( SelectedEntity::CollisionTile(_, _) => { entity_mutated = false; } + SelectedEntity::ObjectIdTile(_, _) => { + entity_mutated = false; + } } if entity_mutated && old_value != value { state.push_undo(MapEditAction { @@ -204,6 +208,20 @@ pub fn undo(app: &mut App, tab_id: usize) -> Task { ev.event_id = val; } } + SelectedEntity::ObjectIdTile(tx, ty) => { + if state.data.can_mutate_map_data() + && let LoadingState::Loaded(ref mut handle) = state.data.loading_state + { + let map_data = Arc::get_mut(&mut handle.0) + .expect("MapData Arc has unexpected shared reference"); + let val = action.old_value.parse::().unwrap_or(0); + if val == 0 { + map_data.object_ids.remove(&(tx, ty)); + } else { + map_data.object_ids.insert((tx, ty), val); + } + } + } } if let Some(idx) = npc_idx && let Some(ref game_path) = app.state.workspace.game_path @@ -280,6 +298,20 @@ pub fn redo(app: &mut App, tab_id: usize) -> Task { } } } + SelectedEntity::ObjectIdTile(tx, ty) => { + if state.data.can_mutate_map_data() + && let LoadingState::Loaded(ref mut handle) = state.data.loading_state + { + let map_data = Arc::get_mut(&mut handle.0) + .expect("MapData Arc has unexpected shared reference"); + let val = action.new_value.parse::().unwrap_or(0); + if val == 0 { + map_data.object_ids.remove(&(tx, ty)); + } else { + map_data.object_ids.insert((tx, ty), val); + } + } + } } // Recompute the NPC sprite if waypoint1_facing_direction was re-applied. @@ -443,4 +475,137 @@ mod tests { assert_eq!(state.data.draw_items[0].y_coord, 77); assert_eq!(state.data.undo_stack.len(), 1); } + + // ── ObjectIdTile undo/redo ────────────────────────────────────────────────── + + /// Helper: create an App with a map editor tab that has a loaded MapData + /// containing one object_id entry at (10, 10) = 42. + fn app_with_object_ids() -> (App, usize) { + use crate::editors::map_editor::message::MapDataHandle; + use dispel_core::map::{MapData, MapModel}; + use std::collections::HashMap; + + let tab_id = 99; + let mut app = App::new().0; + let state = app.state.editors.map_editors.entry(tab_id).or_default(); + + let model = MapModel { + border_count: 2, + tiled_map_width: 50, + tiled_map_height: 50, + map_width_in_pixels: 3200, + map_height_in_pixels: 1600, + map_non_occluded_start_x: 0, + map_non_occluded_start_y: 0, + occluded_map_in_pixels_width: 3200, + occluded_map_in_pixels_height: 1600, + }; + let mut object_ids = HashMap::new(); + object_ids.insert((10, 10), 42); + let map_data = MapData { + model, + gtl_tiles: HashMap::new(), + btl_tiles: HashMap::new(), + collisions: HashMap::new(), + events: HashMap::new(), + object_ids, + tiled_infos: vec![], + internal_sprites: vec![], + sprite_blocks: vec![], + }; + state.data.loading_state = + LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); + + app.state + .workspace + .tabs + .push(crate::workspace::WorkspaceTab { + id: tab_id, + label: "test".into(), + path: None, + editor_type: crate::workspace::EditorType::MapEditor, + modified: false, + pinned: false, + }); + app.state.workspace.active_tab = Some(0); + (app, tab_id) + } + + /// Simulate painting object_id at (10,10) from 42 to 99 via push_undo, + /// then undo it. + #[test] + fn test_object_id_undo_restores_previous_value() { + let (mut app, tab_id) = app_with_object_ids(); + + // Push an undo action as if we painted from 42→99. + { + let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap(); + state.push_undo(MapEditAction { + entity: SelectedEntity::ObjectIdTile(10, 10), + field: "object_id".into(), + old_value: "42".into(), + new_value: "99".into(), + }); + } + + let _task = undo(&mut app, tab_id); + + let state = app.state.editors.map_editors.get(&tab_id).unwrap(); + if let LoadingState::Loaded(ref handle) = state.data.loading_state { + assert_eq!(handle.0.object_ids.get(&(10, 10)), Some(&42)); + } else { + panic!("MapData not loaded"); + } + } + + /// Push undo for painting from 0→55 (new tile), then undo should remove it. + #[test] + fn test_object_id_undo_clears_when_previous_was_zero() { + let (mut app, tab_id) = app_with_object_ids(); + + { + let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap(); + state.push_undo(MapEditAction { + entity: SelectedEntity::ObjectIdTile(20, 20), + field: "object_id".into(), + old_value: "0".into(), + new_value: "55".into(), + }); + } + + let _task = undo(&mut app, tab_id); + + let state = app.state.editors.map_editors.get(&tab_id).unwrap(); + if let LoadingState::Loaded(ref handle) = state.data.loading_state { + assert!(!handle.0.object_ids.contains_key(&(20, 20))); + } else { + panic!("MapData not loaded"); + } + } + + /// Undo then redo should restore the new value. + #[test] + fn test_object_id_redo_reapplies_new_value() { + let (mut app, tab_id) = app_with_object_ids(); + + { + let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap(); + state.push_undo(MapEditAction { + entity: SelectedEntity::ObjectIdTile(10, 10), + field: "object_id".into(), + old_value: "42".into(), + new_value: "99".into(), + }); + } + + let _task = undo(&mut app, tab_id); + let _task = redo(&mut app, tab_id); + + let state = app.state.editors.map_editors.get(&tab_id).unwrap(); + if let LoadingState::Loaded(ref handle) = state.data.loading_state { + assert_eq!(handle.0.object_ids.get(&(10, 10)), Some(&99)); + } else { + panic!("MapData not loaded"); + } + } } diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 2b73b292..55707551 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -243,6 +243,9 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { Some(SelectedEntity::EventTile(tx, ty)) => { format!("Event tile ({},{}) detected!", tx, ty) } + Some(SelectedEntity::ObjectIdTile(tx, ty)) => { + format!("Object ID tile ({},{}) detected!", tx, ty) + } Some(SelectedEntity::Monster(i)) => format!("Monster {} detected", i), Some(SelectedEntity::Npc(i)) => format!("NPC {} detected", i), Some(SelectedEntity::Extra(i)) => format!("Extra {} detected", i), @@ -277,6 +280,36 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { set_tab_modified(app, tab_id, true); } } + Some(SelectedEntity::ObjectIdTile(tx, ty)) => { + if !state.data.can_mutate_map_data() { + state.data.status_msg = Some( + "Cannot edit object IDs while save/export is in progress".into(), + ); + } else if let LoadingState::Loaded(ref mut handle) = + state.data.loading_state + { + let brush = state.data.object_brush.clamp(1, 511); + let map_data = Arc::get_mut(&mut handle.0) + .expect("MapData Arc has unexpected shared reference"); + let old = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0); + let new = if old == brush { 0 } else { brush }; + if new == 0 { + map_data.object_ids.remove(&(tx, ty)); + } else { + map_data.object_ids.insert((tx, ty), new); + } + state.push_undo(MapEditAction { + entity: SelectedEntity::ObjectIdTile(tx, ty), + field: "object_id".into(), + old_value: old.to_string(), + new_value: new.to_string(), + }); + state.view.selected_entity = None; + state.view.overlay_cache.clear(); + state.view.tile_layer_cache.clear(); + set_tab_modified(app, tab_id, true); + } + } _ => { state.view.selected_entity = clicked; state.view.overlay_cache.clear(); @@ -344,6 +377,13 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { } Task::none() } + + MapEditorMessage::SetObjectBrush(tab_id, value) => { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { + state.data.object_brush = value.clamp(1, 511); + } + Task::none() + } } } diff --git a/dispel-gui/src/editors/map_editor/view/inspector.rs b/dispel-gui/src/editors/map_editor/view/inspector.rs index e5335bda..b74814da 100644 --- a/dispel-gui/src/editors/map_editor/view/inspector.rs +++ b/dispel-gui/src/editors/map_editor/view/inspector.rs @@ -176,6 +176,28 @@ pub fn build_inspector<'a>( }; ("Event Inspector", 240.0, body) } + SelectedEntity::ObjectIdTile(tx, ty) => { + let body = if let Some(map_handle) = state.map_data() { + let map_data = &map_handle.0; + let current_id = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0); + column![ + text(format!("Object ID at ({}, {})", tx, ty)).size(12), + horizontal_rule(1), + row![ + text("object_id").size(11).width(140.0).style(style::subtle_text), + text(current_id.to_string()).size(11), + ] + .spacing(6) + .align_y(iced::Alignment::Center), + text("Use the brush toolbar to paint object IDs.").size(11).style(style::subtle_text), + ] + .spacing(8) + .into() + } else { + text("Map data not loaded").size(11).into() + }; + ("Object ID Inspector", 240.0, body) + } }; let header = row![ diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index a04aab9f..51533004 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -6,7 +6,7 @@ use crate::message::{Message, MessageExt}; use crate::style; use gui_widgets::components::modal::modal; use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; -use iced::widget::{button, canvas, column, container, progress_bar, row, stack, text, toggler}; +use iced::widget::{button, canvas, column, container, progress_bar, row, stack, text, text_input, toggler}; use iced::{Element, Fill}; use lucide_icons::Icon; @@ -130,6 +130,22 @@ pub fn view(app: &App) -> Element<'_, Message> { text("Decoding tiles…").size(10).style(style::subtle_text) }; + let obj_id_brush: Element<'_, Message> = { + let brush_str = state.data.object_brush.to_string(); + row![ + text("Brush:").size(10).style(style::subtle_text), + text_input("1", brush_str) + .width(iced::Length::Fixed(48.0)) + .on_input(move |v: String| { + let val = v.parse::().unwrap_or(1).clamp(1, 511); + Message::map_editor(MapEditorMessage::SetObjectBrush(tab_id, val)) + }), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() + }; + let layer_row = row![ text("Layers:").size(11).style(style::subtle_text), layer_toggle( @@ -216,6 +232,7 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::ObjectIds, None ), + obj_id_brush, ] .spacing(12) .padding([6, 16]) From cab895be980898e78c49b069b43e500b6a047ca0 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:38:49 +0200 Subject: [PATCH 05/49] Update docs --- docs/files/AllMap.ini.md | 70 +-- docs/files/CROSS_REFERENCES.md | 125 +++-- docs/files/CharacterInGame/ChData.db.md | 35 +- docs/files/CharacterInGame/EditItem.db.md | 66 ++- docs/files/CharacterInGame/EventItem.db.md | 111 +--- docs/files/CharacterInGame/HealItem.db.md | 31 +- docs/files/CharacterInGame/MiscItem.db.md | 13 +- docs/files/CharacterInGame/Monster.ini.md | 75 +-- docs/files/CharacterInGame/Store.db.md | 44 +- docs/files/CharacterInGame/WeaponItem.db.md | 77 ++- docs/files/Event.ini.md | 94 +--- docs/files/Extra.ini.md | 103 +--- docs/files/ExtraInGame/Extra.ref.md | 248 ++++---- docs/files/ExtraInGame/Message.scr.md | 19 +- docs/files/ExtraInGame/Quest.scr.md | 27 +- docs/files/MagicInGame/Magic.db.md | 68 +-- docs/files/Map/Map.btl.md | 159 ++---- docs/files/Map/Map.gtl.md | 154 ++--- docs/files/Map/Map.map.md | 105 ++-- docs/files/Map/MapModule.md | 540 +++++++++++++----- docs/files/MonsterInGame/MondunMonmap.ref.md | 93 ++- docs/files/MonsterInGame/Monster.db.md | 198 ++----- docs/files/Npc.ini.md | 64 +-- docs/files/NpcInGame/DlgMapFiles.dlg.md | 31 +- docs/files/NpcInGame/Eventnpc.ref.md | 21 +- docs/files/NpcInGame/NpcMapFiles.ref.md | 68 ++- docs/files/NpcInGame/PgpMapFiles.pgp.md | 43 +- docs/files/NpcInGame/PrtIni.db.md | 37 +- docs/files/NpcInGame/PrtLevel.db.md | 59 +- docs/files/Ref/DrawItem.db.md | 27 +- docs/files/Ref/Map.ini.md | 54 +- docs/files/Ref/PartyRef.ref.md | 109 +--- docs/files/SaveFile.md | 559 ++++++++++--------- docs/files/Sprites.spr.md | 123 ++-- docs/files/Wave.ini.md | 22 +- src/references/save_file/events.rs | 8 +- 36 files changed, 1785 insertions(+), 1895 deletions(-) diff --git a/docs/files/AllMap.ini.md b/docs/files/AllMap.ini.md index 69317d89..70b38088 100644 --- a/docs/files/AllMap.ini.md +++ b/docs/files/AllMap.ini.md @@ -1,5 +1,8 @@ # AllMap.ini Documentation +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information - **Location**: `Dispel/AllMap.ini` (relative to game installation directory) @@ -7,9 +10,11 @@ - **Format**: CSV (Comma-Separated Values) - **Comment lines**: Lines starting with `;` are ignored -`AllMap.ini` is a master map list file used by the game engine to index all available maps and their associated resources. +`AllMap.ini` is a master map list file used by the game engine to index all available maps and their associated +resources. ## Structure + Each line represents one map with the following fields: ``` @@ -17,64 +22,49 @@ id,map_file,name,pgp,dlg,lit ``` ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| `id` | Integer | Unique map identifier | -| `map_file` | String | Filename of the .map file without an extension (e.g., "map1") | -| `name` | String | Display name shown in-game | -| `pgp` | String/Null | Conversation script filename or "null" if absent | -| `dlg` | String/Null | Dialog text filename or "null" if absent | -| `lit` | Integer | Lighting indicator: `0` = dark/dungeon, `1` = lit/outdoor | -## Example Entries +| Field | Type | Description | +|------------|-------------|---------------------------------------------------------------| +| `id` | Integer | Unique map identifier | +| `map_file` | String | Filename of the .map file without an extension (e.g., "cat1") | +| `name` | String | Display name shown in-game | +| `pgp` | String/Null | Conversation script filename or "null" if absent | +| `dlg` | String/Null | Dialog text filename or "null" if absent | +| `lit` | Integer | Lighting indicator: `0` = dark/dungeon, `1` = lit/outdoor | + +### Example Entries ``` -0,map1,Aesh,Pgpmap1.pgp,Dlgmap1.dlg,0 -1,map2,Shereg,Pgpmap2.pgp,Dlgmap2.dlg,0 -2,map3,Yam,Pgpmap3.pgp,Dlgmap3.dlg,0 +1,cat1,Forest,Pgpcat1.pgp,Dlgcat1.dlg,1 +2,cat2,Dungeon,Pgpcat2.pgp,Dlgcat2.dlg,0 +3,cat3,Village,null,null,1 ``` -## Special Values +### Special Values + - **"null"**: Used for `pgp` and `dlg` fields when the file is absent - **Lighting**: `0` indicates dark/dungeon maps, `1` indicates lit/outdoor maps ## Purpose -This file serves as the master index for all game maps, linking map IDs to their respective filenames and metadata. The game engine uses this file to: + +This file serves as the master index for all game maps, linking map IDs to their respective filenames and metadata. The +game engine uses this file to: + 1. Load the correct map files 2. Associate party (PGP) and dialog (DLG) files with maps 3. Determine lighting conditions for rendering -## Legal Notice +## Parser -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, map data, or proprietary assets. All references to map configurations are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -### Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any map data or game content -- Focuses on **technical organization**, not creative content -- Uses **generic descriptions** of file purposes -- Maintains **nominal fair use** for trademark references - -## Extractor - -An extractor is available in `src/references/all_map_ini.rs` to parse this file format. +The file is parsed by the `Map` struct in `src/references/all_map_ini.rs`, which implements the `Extractor` and +`Localizable` traits. ### How to Run ```bash # Extract AllMap.ini to JSON -cargo run -- extract -i "fixtures/Dispel/AllMap.ini" +cargo run -- extract -i "AllMap.ini" # Import to SQLite database -cargo run -- database import "fixtures/Dispel/" "database.sqlite" +cargo run -- database import "path/to/Dispel/" "database.sqlite" ``` - -## Notes -- The file is parsed by the `Map` struct in `src/references/all_map_ini.rs` -- Maps are stored in a database using the `save_maps` function -- The file format is strictly enforced with WINDOWS-1250 encoding -- **No copyrighted game content** is reproduced or distributed diff --git a/docs/files/CROSS_REFERENCES.md b/docs/files/CROSS_REFERENCES.md index ff787050..74e8dd6d 100644 --- a/docs/files/CROSS_REFERENCES.md +++ b/docs/files/CROSS_REFERENCES.md @@ -2,11 +2,13 @@ ## Overview -This document maps the relationships and cross-references between all game data files in the Dispel game system. The files form a complex interconnected database that drives the game's mechanics, visuals, and interactions. +This document maps the relationships and cross-references between all game data files in the Dispel game system. The +files form a complex interconnected database that drives the game's mechanics, visuals, and interactions. ## File Dependency Map ### Core Configuration Files (INI) + - **AllMap.ini**: Master map index linking maps to resources - **Map.ini** (Ref/): Map initialization with positions and resource links - **Monster.ini** (CharacterInGame/): Monster visual definitions and animations @@ -16,6 +18,7 @@ This document maps the relationships and cross-references between all game data - **Wave.ini**: Sound effect mappings ### Database Files (DB) + - **Monster.db** (MonsterInGame/): Monster combat statistics and AI - **WeaponItem.db** (CharacterInGame/): Weapons and armor with stats - **HealItem.db** (CharacterInGame/): Consumable healing items @@ -29,6 +32,7 @@ This document maps the relationships and cross-references between all game data - **DrawItem.db** (Ref/): Object placements on maps ### Reference Files (REF) + - **PartyRef.ref** (Ref/): Party character definitions - **Eventnpc.ref** (NpcInGame/): Event-triggered NPC definitions - **NpcMapFiles.ref** (NpcInGame/): NPC placements with waypoints @@ -36,12 +40,14 @@ This document maps the relationships and cross-references between all game data - **MondunMonmap.ref** (MonsterInGame/): Monster placements on maps ### Script Files (SCR/DLG/PGP) + - **Message.scr** (ExtraInGame/): UI text messages - **Quest.scr** (ExtraInGame/): Quest journal entries - **DlgMapFiles.dlg** (NpcInGame/): Dialogue conversation scripts - **PgpMapFiles.pgp** (NpcInGame/): Dialogue text content ### Visual/Audio Assets + - **Sprites.spr** (Map/): Sprite files for characters, monsters, objects - **SNF files** (referenced by Wave.ini): Sound effect files - **Map.map** (Map/): Map geometry and tile data @@ -49,6 +55,7 @@ This document maps the relationships and cross-references between all game data - **Map.btl** (Map/): Building/roof tileset files ### Map Tileset Relationships + ``` Map.map files ├── gtl_tile_id → Map.gtl (ground tiles) @@ -266,57 +273,57 @@ Message.scr (UI messages) ## Complete Cross-Reference Table -| Source File | Source Field | Target File | Target Field | Relationship | -|-------------|--------------|-------------|--------------|--------------| -| AllMap.ini | map_file | Map.map | filename | Map geometry | -| AllMap.ini | pgp | PgpMapFiles.pgp | filename | Dialogue text | -| AllMap.ini | dlg | DlgMapFiles.dlg | filename | Dialogue scripts | -| Map.ini | monsters | MondunMonmap.ref | filename | Monster placements | -| Map.ini | npcs | NpcMapFiles.ref | filename | NPC placements | -| Map.ini | extras | Extra.ref | filename | Object placements | -| Map.ini | camera_event | Event.ini | event_id | Camera trigger | -| Monster.ini | sprite_filename | Sprites.spr | filename | Visual assets | -| Npc.ini | sprite_filename | Sprites.spr | filename | Visual assets | -| Extra.ini | sprite_filename | Sprites.spr | filename | Visual assets | -| PartyRef.ref | npc_id | Npc.ini | id | Visual appearance | -| PartyRef.ref | map_id | AllMap.ini | id | Location | -| PartyRef.ref | dlg_out | DlgMapFiles.dlg | id | Dialogue | -| PartyRef.ref | dlg_in | DlgMapFiles.dlg | id | Dialogue | -| NpcMapFiles.ref | npc_id | Npc.ini | id | NPC type | -| NpcMapFiles.ref | dialog_id | DlgMapFiles.dlg | id | Dialogue | -| NpcMapFiles.ref | show_on_event | Event.ini | event_id | Visibility | -| Eventnpc.ref | event_id | Event.ini | event_id | Trigger | -| DlgMapFiles.dlg | prev_event | Event.ini | event_id | Prerequisite | -| DlgMapFiles.dlg | next_dlg | DlgMapFiles.dlg | id | Chain | -| DlgMapFiles.dlg | dlg_id | PgpMapFiles.pgp | id | Text | -| DlgMapFiles.dlg | event_id | Event.ini | event_id | Trigger | -| Extra.ref | ext_id | Extra.ini | id | Object type | -| Extra.ref | event_id | Event.ini | event_id | Interaction | -| Extra.ref | message_id | Message.scr | id | Sign text | -| Extra.ref | required_item_type_id | Item type enum | - | Key type | -| Extra.ref | item_type_id | Item type enum | - | Content type | -| Monster.db | known_spell_slot1-3 | Magic.db | id | Spells | -| DrawItem.db | map_id | AllMap.ini | id | Map location | -| DrawItem.db | item_id | Extra.ini | id | Object type | -| Map.map | gtl_tile_id | Map.gtl | id | Ground tiles | -| Map.map | btl_tile_id | Map.btl | id | Building tiles | -| Map.map | event_id | Event.ini | event_id | Tile events | -| Store.db | products | Item databases | id | Inventory | -| Store.db | products (type=0,1) | WeaponItem.db | id | Weapons/Armor | -| Store.db | products (type=2) | HealItem.db | id | Healing items | -| Store.db | products (type=3) | MiscItem.db | id | Misc items | -| Store.db | products (type=4) | EditItem.db | id | Modifiable items | -| Extra.ref | item_type_id=1 | WeaponItem.db | id | Weapons/Armor | -| Extra.ref | item_type_id=2 | HealItem.db | id | Healing items | -| Extra.ref | item_type_id=3 | EditItem.db | id | Modifiable items | -| Extra.ref | item_type_id=4 | EventItem.db | id | Quest items | -| Extra.ref | item_type_id=5 | MiscItem.db | id | Misc items | -| MondunMonmap.ref | loot*_item_type=1 | WeaponItem.db | id | Weapon loot | -| MondunMonmap.ref | loot*_item_type=2 | HealItem.db | id | Healing loot | -| MondunMonmap.ref | loot*_item_type=3 | EditItem.db | id | Editable loot | -| MondunMonmap.ref | loot*_item_type=4 | EventItem.db | id | Event loot | -| MondunMonmap.ref | loot*_item_type=5 | MiscItem.db | id | Misc loot | -| Wave.ini | snf_filename | .snf files | filename | Audio | +| Source File | Source Field | Target File | Target Field | Relationship | +|------------------|-----------------------|------------------|--------------|--------------------| +| AllMap.ini | map_file | Map.map | filename | Map geometry | +| AllMap.ini | pgp | PgpMapFiles.pgp | filename | Dialogue text | +| AllMap.ini | dlg | DlgMapFiles.dlg | filename | Dialogue scripts | +| Map.ini | monsters | MondunMonmap.ref | filename | Monster placements | +| Map.ini | npcs | NpcMapFiles.ref | filename | NPC placements | +| Map.ini | extras | Extra.ref | filename | Object placements | +| Map.ini | camera_event | Event.ini | event_id | Camera trigger | +| Monster.ini | sprite_filename | Sprites.spr | filename | Visual assets | +| Npc.ini | sprite_filename | Sprites.spr | filename | Visual assets | +| Extra.ini | sprite_filename | Sprites.spr | filename | Visual assets | +| PartyRef.ref | npc_id | Npc.ini | id | Visual appearance | +| PartyRef.ref | map_id | AllMap.ini | id | Location | +| PartyRef.ref | dlg_out | DlgMapFiles.dlg | id | Dialogue | +| PartyRef.ref | dlg_in | DlgMapFiles.dlg | id | Dialogue | +| NpcMapFiles.ref | npc_id | Npc.ini | id | NPC type | +| NpcMapFiles.ref | dialog_id | DlgMapFiles.dlg | id | Dialogue | +| NpcMapFiles.ref | show_on_event | Event.ini | event_id | Visibility | +| Eventnpc.ref | event_id | Event.ini | event_id | Trigger | +| DlgMapFiles.dlg | prev_event | Event.ini | event_id | Prerequisite | +| DlgMapFiles.dlg | next_dlg | DlgMapFiles.dlg | id | Chain | +| DlgMapFiles.dlg | dlg_id | PgpMapFiles.pgp | id | Text | +| DlgMapFiles.dlg | event_id | Event.ini | event_id | Trigger | +| Extra.ref | ext_id | Extra.ini | id | Object type | +| Extra.ref | event_id | Event.ini | event_id | Interaction | +| Extra.ref | message_id | Message.scr | id | Sign text | +| Extra.ref | required_item_type_id | Item type enum | - | Key type | +| Extra.ref | item_type_id | Item type enum | - | Content type | +| Monster.db | known_spell_slot1-3 | Magic.db | id | Spells | +| DrawItem.db | map_id | AllMap.ini | id | Map location | +| DrawItem.db | item_id | Extra.ini | id | Object type | +| Map.map | gtl_tile_id | Map.gtl | id | Ground tiles | +| Map.map | btl_tile_id | Map.btl | id | Building tiles | +| Map.map | event_id | Event.ini | event_id | Tile events | +| Store.db | products | Item databases | id | Inventory | +| Store.db | products (type=0,1) | WeaponItem.db | id | Weapons/Armor | +| Store.db | products (type=2) | HealItem.db | id | Healing items | +| Store.db | products (type=3) | MiscItem.db | id | Misc items | +| Store.db | products (type=4) | EditItem.db | id | Modifiable items | +| Extra.ref | item_type_id=1 | WeaponItem.db | id | Weapons/Armor | +| Extra.ref | item_type_id=2 | HealItem.db | id | Healing items | +| Extra.ref | item_type_id=3 | EditItem.db | id | Modifiable items | +| Extra.ref | item_type_id=4 | EventItem.db | id | Quest items | +| Extra.ref | item_type_id=5 | MiscItem.db | id | Misc items | +| MondunMonmap.ref | loot*_item_type=1 | WeaponItem.db | id | Weapon loot | +| MondunMonmap.ref | loot*_item_type=2 | HealItem.db | id | Healing loot | +| MondunMonmap.ref | loot*_item_type=3 | EditItem.db | id | Editable loot | +| MondunMonmap.ref | loot*_item_type=4 | EventItem.db | id | Event loot | +| MondunMonmap.ref | loot*_item_type=5 | MiscItem.db | id | Misc loot | +| Wave.ini | snf_filename | .snf files | filename | Audio | ## Data Flow Summary @@ -334,6 +341,7 @@ Message.scr (UI messages) ## Item Database Relationships ### Item Type Enum (ItemTypeId) + ``` 1: Weapon → WeaponItem.db 2: Healing → HealItem.db @@ -344,6 +352,7 @@ Message.scr (UI messages) ``` ### Item Database Cross-References + ``` WeaponItem.db (weapons/armor) ├── Referenced by Store.db products (type=1) @@ -372,15 +381,16 @@ MiscItem.db (generic utility items) ## File Encoding Summary -| Encoding | Files | -|----------|-------| -| EUC-KR | Event.ini, Extra.ini, Npc.ini, Wave.ini, Map.ini, Monster.db, DlgMapFiles.dlg, HealItem.db (descriptions) | +| Encoding | Files | +|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| EUC-KR | Event.ini, Extra.ini, Npc.ini, Wave.ini, Map.ini, Monster.db, DlgMapFiles.dlg, HealItem.db (descriptions) | | WINDOWS-1250 | AllMap.ini, Monster.ini, Store.db, WeaponItem.db, HealItem.db (names), MiscItem.db, EditItem.db, EventItem.db, Message.scr, Quest.scr, PgpMapFiles.pgp, Extra.ref, NpcMapFiles.ref, PartyRef.ref, Eventnpc.ref | -| Binary | Monster.db, WeaponItem.db, HealItem.db, MiscItem.db, EditItem.db, EventItem.db, Store.db, ChData.db, PrtLevel.db, Magic.db, DrawItem.db, Map.map, Map.gtl, Map.btl, Sprites.spr | +| Binary | Monster.db, WeaponItem.db, HealItem.db, MiscItem.db, EditItem.db, EventItem.db, Store.db, ChData.db, PrtLevel.db, Magic.db, DrawItem.db, Map.map, Map.gtl, Map.btl, Sprites.spr | ## Legal Compliance This cross-reference documentation: + - Describes **technical relationships between file formats only** - Does **not distribute any copyrighted game content** - Focuses on **data structure organization and system design** @@ -451,4 +461,5 @@ cargo run -- database import "path/to/Dispel/" "database.sqlite" --- -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. +**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the +trademark owner. diff --git a/docs/files/CharacterInGame/ChData.db.md b/docs/files/CharacterInGame/ChData.db.md index e1af059b..64ea3d15 100644 --- a/docs/files/CharacterInGame/ChData.db.md +++ b/docs/files/CharacterInGame/ChData.db.md @@ -1,5 +1,8 @@ # ChData.db - Character Initial Attributes +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information - **Location**: `CharacterInGame/ChData.db` @@ -18,7 +21,7 @@ 16 × `i16` — Base attributes (STR, CON, WIS, AGI) for each of 4 classes: | Offset | Class | Fields (each i16) | -| ------ | ------- | ------------------ | +|--------|---------|--------------------| | 30 | Warrior | STR, CON, WIS, AGI | | 38 | Knight | STR, CON, WIS, AGI | | 46 | Archer | STR, CON, WIS, AGI | @@ -30,15 +33,16 @@ ### Derived Stat Bonuses (20 bytes) -5 × `i32` — per-class derived-stat bonuses applied at character creation. The game reads only the low byte of each (values are small, e.g. 5). Each bonus is added to a derived combat stat: +5 × `i32` — per-class derived-stat bonuses applied at character creation. Only the low byte of each is significant +(values are small, e.g. 5). Each bonus is added to a derived combat stat: -| Offset | Class | Field | Derived stat | Base attr | -| ------ | ----- | ----- | ------------ | --------- | -| 64 | Warrior | warrior_offense_bonus | offense | STR | -| 68 | Knight | knight_defense_bonus | defense | AGI | -| 72 | Archer | archer_dodge_bonus | dodge_rate | CON | -| 76 | Archer | archer_hit_bonus | hit_rate | CON | -| 80 | Mage | mage_magic_power_bonus | magic_power | WIS | +| Offset | Class | Field | Derived stat | Base attr | +|--------|---------|------------------------|--------------|-----------| +| 64 | Warrior | warrior_offense_bonus | offense | STR | +| 68 | Knight | knight_defense_bonus | defense | AGI | +| 72 | Archer | archer_dodge_bonus | dodge_rate | CON | +| 76 | Archer | archer_hit_bonus | hit_rate | CON | +| 80 | Mage | mage_magic_power_bonus | magic_power | WIS | > Points-per-level is hardcoded to 5 in the game and is NOT read from this file. @@ -63,7 +67,8 @@ ### Derived Stat Bonuses (warrior_offense_bonus through mage_magic_power_bonus) -- 5 × i32 — per-class derived-stat bonuses. Each is added to a derived combat stat (offense, defense, dodge_rate, hit_rate, magic_power) for the corresponding class. +- 5 × i32 — per-class derived-stat bonuses. Each is added to a derived combat stat (offense, defense, dodge_rate, + hit_rate, magic_power) for the corresponding class. ### mage_magic_power_bonus @@ -126,11 +131,11 @@ Bytes 80-83: mage_magic_power_bonus (i32) ### Byte Offsets -- `0x00-0x1D`: unused_name (30 bytes) -- `0x1E-0x3D`: Class attributes (16 × i16) -- `0x3E-0x3F`: reserved_stat (i16) -- `0x40-0x4F`: Derived stat bonuses (4 × i32) -- `0x50-0x53`: Mage magic power bonus (i32) +- 0–29: unused_name (30 bytes) +- 30–61: Class attributes (16 × i16) +- 62–63: reserved_stat (i16) +- 64–79: Derived stat bonuses (4 × i32) +- 80–83: Mage magic power bonus (i32) ### Data Types diff --git a/docs/files/CharacterInGame/EditItem.db.md b/docs/files/CharacterInGame/EditItem.db.md index 87eccab9..8f7da5d7 100644 --- a/docs/files/CharacterInGame/EditItem.db.md +++ b/docs/files/CharacterInGame/EditItem.db.md @@ -1,49 +1,47 @@ # EditItem.db -`CharacterInGame/EditItem.db` defines consumable item-modification materials. -It is a little-endian binary file with a four-byte record count followed by fixed-size -268-byte records. Text is encoded as WINDOWS-1250. +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + +`CharacterInGame/EditItem.db` defines consumable item-modification materials. It is a little-endian binary file with a +four-byte record count followed by fixed-size 268-byte records. Text is encoded as WINDOWS-1250. ## Record layout -| Offset | Size | Field | Meaning | -|---:|---:|---|---| -| `0x00` | 30 | `name` | Null-padded item name. | -| `0x1E` | 202 | `description` | Null-padded description. | -| `0xE8` | 4 | `base_price` | Shop price. | -| `0xEC` | 4 | `runtime_item_id` | Runtime ID assigned by the game loader. | -| `0xF0` | 2 | `health_points` | HP modifier. | -| `0xF2` | 2 | `mana_points` | MP modifier. | -| `0xF4` | 2 | `strength` | Strength modifier. | -| `0xF6` | 2 | `agility` | Agility modifier. | -| `0xF8` | 2 | `wisdom` | Wisdom modifier. | -| `0xFA` | 2 | `constitution` | Constitution modifier. | -| `0xFC` | 2 | `to_dodge` | Dodge modifier. | -| `0xFE` | 2 | `to_hit` | Hit modifier. | -| `0x100` | 2 | `offense` | Offense modifier. | -| `0x102` | 2 | `defense` | Defense modifier. | -| `0x104` | 2 | `magical_power` | Magical-power modifier. | -| `0x106` | 2 | `modification_resistance` | Resistance to modification; a negative value lowers resistance. | -| `0x108` | 1 | `reserved_byte` | Always zero in shipped data; no runtime use identified. | -| `0x109` | 1 | `modifies_item` | Whether this material can modify an item. | -| `0x10A` | 2 | `additional_effect` | Extra effect: none, fire, or mana drain. | +| Offset | Size | Field | Meaning | +|-------:|-----:|---------------------------|-----------------------------------------------------------------| +| 0 | 30 | `name` | Null-padded item name. | +| 30 | 202 | `description` | Null-padded description. | +| 232 | 4 | `base_price` | Shop price. | +| 236 | 4 | `runtime_item_id` | Runtime ID assigned by the game loader. | +| 240 | 2 | `health_points` | HP modifier. | +| 242 | 2 | `mana_points` | MP modifier. | +| 244 | 2 | `strength` | Strength modifier. | +| 246 | 2 | `agility` | Agility modifier. | +| 248 | 2 | `wisdom` | Wisdom modifier. | +| 250 | 2 | `constitution` | Constitution modifier. | +| 252 | 2 | `to_dodge` | Dodge modifier. | +| 254 | 2 | `to_hit` | Hit modifier. | +| 256 | 2 | `offense` | Offense modifier. | +| 258 | 2 | `defense` | Defense modifier. | +| 260 | 2 | `magical_power` | Magical-power modifier. | +| 262 | 2 | `modification_resistance` | Resistance to modification; a negative value lowers resistance. | +| 264 | 1 | `reserved_byte` | Always zero by default; purpose unknown. | +| 265 | 1 | `modifies_item` | Whether this material can modify an item. | +| 266 | 2 | `additional_effect` | Extra effect: none, fire, or mana drain. | ## Runtime item ID -The four bytes at `0xEC` are not two independent padding fields. The on-disk database -stores zero there, then the game loader overwrites the word with the zero-based record -index. Inventory and ground-item records preserve that value as `edit_item_id` to link -an instance back to its database definition. +The four bytes at byte offset 236 are not two independent padding fields. The on-disk database stores zero there, then +the game loader overwrites the word with the zero-based record index. Inventory and ground-item records preserve that +value as `edit_item_id` to link an instance back to its database definition. -When writing the original database, retain the stored value (normally zero). The game -will assign the runtime ID when it loads the file. +When writing the original database, retain the stored value (normally zero). The game will assign the runtime ID when it +loads the file. ## Notes -- Record size: 268 bytes (`0x10C`). +- Record size: 268 bytes. - Header: signed 32-bit record count. - All numeric fields are little-endian. - `reserved_byte` is an explicit byte in the binary layout, not compiler alignment. - -**DISPEL®** is a registered trademark. This project is not affiliated with, endorsed -by, or sponsored by the trademark owner. diff --git a/docs/files/CharacterInGame/EventItem.db.md b/docs/files/CharacterInGame/EventItem.db.md index 2c85e852..a69bef0a 100644 --- a/docs/files/CharacterInGame/EventItem.db.md +++ b/docs/files/CharacterInGame/EventItem.db.md @@ -1,19 +1,21 @@ -# EventItem.db Documentation +# EventItem.db + +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. ## File Information ### Overview -Binary database file that defines quest and event items with names and descriptions for the game's quest progression and event triggering system. +Binary database file that defines quest and event items with names and descriptions for the game's quest progression and +event triggering system. ### File Structure **Location**: `CharacterInGame/EventItem.db` **Encoding**: Binary (Little-Endian) **Text Encoding**: WINDOWS-1250 (Central European) -**Header**: 4-byte record count -**Record Size**: 240 bytes (60 × 4-byte fields) -**Total Records**: Variable (determined by header) +**Header**: 4-byte record count **Record Size**: 240 bytes **Total Records**: Variable (determined by header) ### Binary Format @@ -24,17 +26,19 @@ Binary database file that defines quest and event items with names and descripti [Records: 240 bytes each] - name: 30 bytes (WINDOWS-1250, null-padded) - description: 202 bytes (WINDOWS-1250, null-padded) -- padding: 8 bytes (unused) +- base_price: i32 +- padding: 4 bytes (unused) ``` ### Field Definitions -| Field | Size | Type | Description | -|-------|------|------|-------------| -| id | N/A | i32 | Record index (assigned during parsing) | -| name | 30 | string | Quest item name (WINDOWS-1250 encoded) | -| description | 202 | string | Item description (WINDOWS-1250 encoded) | -| padding | 8 | bytes | Unused padding bytes | +| Field | Size | Type | Description | +|-------------|------|--------|-----------------------------------------| +| id | N/A | i32 | Record index (assigned during parsing) | +| name | 30 | string | Quest item name (WINDOWS-1250 encoded) | +| description | 202 | string | Item description (WINDOWS-1250 encoded) | +| base_price | 4 | i32 | Item price | +| padding | 4 | bytes | Unused padding bytes | ### Data Structure @@ -45,6 +49,8 @@ pub struct EventItem { id: i32, // Record index (0, 1, 2...) name: String, // Item name (30 chars max) description: String, // Item description (202 chars max) + base_price: i32, // Item price + padding: i32, // Unused padding } ``` @@ -55,106 +61,37 @@ Offset | Size | Field | Description -------|------|-------|------------- 0 | 30 | name | Null-padded WINDOWS-1250 string 30 | 202 | desc | Null-padded WINDOWS-1250 string -232 | 8 | pad | Unused padding bytes +232 | 4 | price | Item price (i32) +236 | 4 | pad | Unused padding bytes ``` ### Special Values - **Null-padded strings**: Fixed-size fields with null termination -- **8-byte padding**: Unused space for alignment +- **4-byte padding**: Unused space for alignment - **Record count**: Determines number of entries - **Fixed record size**: 240 bytes per entry -### Example Data Structure - -Based on the binary format, entries follow this pattern: - -``` -Record 0: -- name: "Quest Item Name" (30 bytes) -- description: "Item description text" (202 bytes) -- padding: 8 null bytes - -Record 1: -- name: "Another Item" (30 bytes) -- description: "Quest-related description" (202 bytes) -- padding: 8 null bytes -``` - ### Technical Details **Text Encoding**: + - WINDOWS-1250 for Central European characters - Null-terminated strings with padding - Fixed field sizes (30 and 202 bytes) **Binary Processing**: + - Little-endian byte order - Fixed record size validation - Null-padded string handling -- 8-byte alignment padding - -**Database Integration**: -- Processed by `EventItem` struct in the codebase -- Stored with id, name, and description fields -- Linked to quest and event systems -- Used for quest progression tracking - -### Usage in Game - -1. **Quest System**: Defines items required for quest completion -2. **Event Triggers**: Items that trigger special events -3. **Inventory Management**: Quest items in player inventory -4. **Story Progression**: Key items for narrative advancement -5. **Unique Items**: Special objects with lore significance - -### File Characteristics - -- **Record Size**: 240 bytes (fixed) -- **Header**: 4-byte record count -- **Encoding**: WINDOWS-1250 for text fields -- **Structure**: Simple name/description pairs -- **Alignment**: 8-byte padding for alignment - -### Technical Analysis - -**Efficiency:** -- Fixed record size enables random access -- Simple structure with minimal overhead -- Padding ensures proper memory alignment - -**Limitations:** -- Fixed-size text fields (30 + 202 = 232 chars) -- No statistical data (pure lore items) -- Basic structure focused on quest functionality - -**Performance:** -- Fast parsing due to fixed record size -- Efficient database storage -- Simple binary format for quick loading ### Notes - File uses binary format with WINDOWS-1250 text encoding -- Simple name/description structure for quest items +- Simple name/description/price structure for quest items - Fixed record size enables efficient parsing - Integrated with quest progression systems -- **No copyrighted game content** is reproduced or distributed - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, quest data, or proprietary assets. All references to item systems are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -## Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any quest data or game content -- Focuses on **technical organization and binary structure** -- Explains **database systems and quest item mechanics** -- Maintains **nominal fair use** for trademark references ## Extractor diff --git a/docs/files/CharacterInGame/HealItem.db.md b/docs/files/CharacterInGame/HealItem.db.md index bcd95c68..cc7319ac 100644 --- a/docs/files/CharacterInGame/HealItem.db.md +++ b/docs/files/CharacterInGame/HealItem.db.md @@ -1,5 +1,8 @@ # HealItem.db +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## Purpose `HealItem.db` stores fixed-size definitions for consumable items that restore health or mana and cure status effects. @@ -13,20 +16,20 @@ ## Record Layout -| Offset | Size | Field | Type | Description | -|---|---:|---|---|---| -| `0x00` | 30 | `name` | string | Null-padded item name. | -| `0x1E` | 202 | `description` | string | Null-padded item description. | -| `0xE8` | 4 | `base_price` | `i32` | Item price. | -| `0xEC` | 4 | `runtime_item_index_slot` | `i32` | Loader-owned index slot. | -| `0xF0` | 2 | `health_points` | `i16` | Health-point change. | -| `0xF2` | 2 | `mana_points` | `i16` | Mana-point change. | -| `0xF4` | 1 | `restores_full_health` | flag | Full-health restore. | -| `0xF5` | 1 | `restores_full_mana` | flag | Full-mana restore. | -| `0xF6` | 1 | `cures_poison` | flag | Poison cure. | -| `0xF7` | 1 | `cures_petrification` | flag | Petrification cure. | -| `0xF8` | 1 | `cures_polymorph` | flag | Polymorph cure. | -| `0xF9` | 3 | `reserved_trailer` | bytes | Preserved opaque data. Unused. | +| Offset | Size | Field | Type | Description | +|--------|-----:|---------------------------|--------|--------------------------------| +| 0 | 30 | `name` | string | Null-padded item name. | +| 30 | 202 | `description` | string | Null-padded item description. | +| 232 | 4 | `base_price` | `i32` | Item price. | +| 236 | 4 | `runtime_item_index_slot` | `i32` | Loader-owned index slot. | +| 240 | 2 | `health_points` | `i16` | Health-point change. | +| 242 | 2 | `mana_points` | `i16` | Mana-point change. | +| 244 | 1 | `restores_full_health` | flag | Full-health restore. | +| 245 | 1 | `restores_full_mana` | flag | Full-mana restore. | +| 246 | 1 | `cures_poison` | flag | Poison cure. | +| 247 | 1 | `cures_petrification` | flag | Petrification cure. | +| 248 | 1 | `cures_polymorph` | flag | Polymorph cure. | +| 249 | 3 | `reserved_trailer` | bytes | Preserved opaque data. Unused. | ## Runtime Behavior diff --git a/docs/files/CharacterInGame/MiscItem.db.md b/docs/files/CharacterInGame/MiscItem.db.md index bea07988..4857f0ad 100644 --- a/docs/files/CharacterInGame/MiscItem.db.md +++ b/docs/files/CharacterInGame/MiscItem.db.md @@ -1,18 +1,21 @@ -# MiscItem.db Documentation +# MiscItem.db + +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. ## File Information ### Overview -Binary database file that defines generic miscellaneous items with names, descriptions, and economic values for the game's crafting, inventory, and utility systems. +Binary database file that defines generic miscellaneous items with names, descriptions, and economic values for the +game's crafting, inventory, and utility systems. ### File Structure **Location**: `CharacterInGame/MiscItem.db` **Encoding**: Binary (Little-Endian) **Text Encodings**: Mixed (WINDOWS-1250 and EUC-KR) -**Header**: 4-byte record count -**Record Size**: 256 bytes (64 × 4-byte fields) +**Header**: 4-byte record count **Record Size**: 256 bytes (64 × 4-byte fields) **Total Records**: Variable (determined by header) ### Binary Format @@ -32,7 +35,7 @@ Binary database file that defines generic miscellaneous items with names, descri ### Field Definitions | Field | Size | Type | Description | -| ------------------------- | ---- | ------ | ------------------------------------------------------------------------------------------- | +|---------------------------|------|--------|---------------------------------------------------------------------------------------------| | id | N/A | i32 | Record index (assigned during parsing) | | name | 30 | string | Item name (WINDOWS-1250 encoded) | | description | 202 | string | Item description (EUC-KR encoded) | diff --git a/docs/files/CharacterInGame/Monster.ini.md b/docs/files/CharacterInGame/Monster.ini.md index 1c6135e5..3ce0545d 100644 --- a/docs/files/CharacterInGame/Monster.ini.md +++ b/docs/files/CharacterInGame/Monster.ini.md @@ -1,4 +1,7 @@ -# Monster.ini Documentation +# Monster.ini + +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. ## File Information @@ -11,7 +14,6 @@ Text file that defines monster visual appearances and animation sequences for th **Location**: `Monster.ini` **Encoding**: WINDOWS-1250 (Central European character encoding) **Format**: CSV (Comma-Separated Values) with comments -**Total Entries**: 37 monster definitions ### Format Specification @@ -26,22 +28,23 @@ id,name,sprite_filename,attack_seq,hit_seq,death_seq,walk_seq,cast_seq ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| id | i32 | Unique monster visual type identifier (0-36) | -| name | string | Monster display name or "null" | -| sprite_filename | string | SPR filename or "null" for no sprite | -| attack_seq | i32 | Animation sequence ID for attacking | -| hit_seq | i32 | Animation sequence ID for taking damage | -| death_seq | i32 | Animation sequence ID for dying | -| walk_seq | i32 | Animation sequence ID for walking | -| cast_seq | i32 | Animation sequence ID for spellcasting | +| Field | Type | Description | +|-----------------|--------|-----------------------------------------| +| id | i32 | Unique monster visual type identifier | +| name | string | Monster display name or "null" | +| sprite_filename | string | SPR filename or "null" for no sprite | +| attack_seq | i32 | Animation sequence ID for attacking | +| hit_seq | i32 | Animation sequence ID for taking damage | +| death_seq | i32 | Animation sequence ID for dying | +| walk_seq | i32 | Animation sequence ID for walking | +| cast_seq | i32 | Animation sequence ID for spellcasting | ### Animation System The file maps monster types to animation sequences that reference frame indices in SPR files: **Animation Sequence IDs:** + - **0**: No animation (unused sequence) - **1-N**: Frame sequence numbers in SPR file - **-1**: Special/alternative animation sequence @@ -56,20 +59,20 @@ The file organizes monsters into logical groups based on the comment header: ``` **Example Monsters:** -- Goblin and variants -- Orc -- Goblin King -- Bandit +- ID 0: Default/null entry (no monster) +- IDs 1+: Various monster types with sprites and animations ### Animation Sequence Details **Combat Animations:** + - `attack_seq`: Attacking/offensive animations - `hit_seq`: Damage/taking hit animations - `death_seq`: Death/dying animations **Movement Animations:** + - `walk_seq`: Walking/movement animations - `cast_seq`: Spellcasting/magic animations @@ -98,16 +101,19 @@ The file organizes monsters into logical groups based on the comment header: ### Technical Details **Encoding**: WINDOWS-1250 (Central European) + - Supports Polish characters in comments and names - Requires proper encoding handling for reading/writing **File Processing**: + - Comments (lines starting with ";") are ignored - Empty lines are skipped - CSV format with comma delimiter - "null" literal used for missing name/sprite fields **Database Integration**: + - Processed by `MonsterIni` struct in the codebase - Stored in database with all field mappings - Linked to monster placement files (mondun*.ref, monmap*.ref) @@ -138,49 +144,12 @@ pub struct MonsterIni { 4. **Movement System**: Applies walking animations during navigation 5. **Magic System**: Triggers casting animations for spellcasters -### Monster Types Analysis - -**Basic Statistics:** -- **Total Monsters**: 37 unique types -- **ID Range**: 0-36 (ID 0 = default/null entry) -- **Animation Coverage**: All monsters have complete animation sets -- **Special Animations**: Some monsters use -1 for special sequences - -**Animation Patterns:** -- Most monsters follow consistent animation sequence numbering -- Boss/elite monsters may have unique animation patterns -- Animal companions use simpler animation sets - -### File Characteristics - -- **Entry Count**: 37 monster definitions -- **ID System**: Sequential with some thematic grouping -- **Comment Structure**: Polish field descriptions -- **Encoding**: WINDOWS-1250 for Central European characters -- **Format**: Strict CSV with consistent field order - ### Notes - File uses Windows-style line endings (\r\n) - Comments are in Polish (WINDOWS-1250 encoding) - Animation sequences link to SPR file frame indices - Integrated with monster placement and combat systems -- **No copyrighted game content** is reproduced or distributed - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game assets, sprite files, or proprietary artwork. All references to monster types are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -## Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any SPR files or game artwork -- Focuses on **technical organization and animation systems** -- Uses **generic examples** of monster structures -- Maintains **nominal fair use** for trademark references ## Extractor diff --git a/docs/files/CharacterInGame/Store.db.md b/docs/files/CharacterInGame/Store.db.md index 65d69ef0..26b74a51 100644 --- a/docs/files/CharacterInGame/Store.db.md +++ b/docs/files/CharacterInGame/Store.db.md @@ -1,19 +1,21 @@ -# Store.db Documentation +# Store.db + +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. ## File Information ### Overview -Binary database file that defines shops and inns with inventories, prices, merchant dialogue, and economic behavior for the game's commerce system. +Binary database file that defines shops and inns with inventories, prices, merchant dialogue, and economic behavior for +the game's commerce system. ### File Structure **Location**: `CharacterInGame/STORE.DB` **Encoding**: Binary (Little-Endian) -**Text Encoding**: WINDOWS-1250 -**Header**: 4-byte record count -**Record Size**: 948 bytes -**Total Records**: Variable (determined by header) +**Text Encoding**: WINDOWS-1250 **Header**: 4-byte record count **Record Size**: 948 bytes **Total Records**: Variable +(determined by header) ### Binary Format @@ -37,27 +39,29 @@ Binary database file that defines shops and inns with inventories, prices, merch ### Field Definitions -| Field | Size | Type | Description | -|-------|------|------|-------------| -| index | N/A | i32 | Record index (assigned during parsing) | -| store_name | 32 | string | Shop/Inn name (WINDOWS-1250 encoded) | -| inn_night_cost | 4 | i32 | Night cost (>0 = inn, 0 = shop) | -| price_modifier | 2 | i16 | Percentage applied to item prices (shops only) | -| products | 60 | array | Product list (shops only, up to 15 slots) | -| padding | 82 | bytes | Always zero (shops) | -| invitation | 512 | string | Merchant greeting (WINDOWS-1250) | -| haggle_success | 128 | string | Successful haggle response | -| haggle_fail | 128 | string | Failed haggle response | +| Field | Size | Type | Description | +|----------------|------|--------|------------------------------------------------| +| index | N/A | i32 | Record index (assigned during parsing) | +| store_name | 32 | string | Shop/Inn name (WINDOWS-1250 encoded) | +| inn_night_cost | 4 | i32 | Night cost (>0 = inn, 0 = shop) | +| price_modifier | 2 | i16 | Percentage applied to item prices (shops only) | +| products | 60 | array | Product list (shops only, up to 15 slots) | +| padding | 82 | bytes | Always zero (shops) | +| invitation | 512 | string | Merchant greeting (WINDOWS-1250) | +| haggle_success | 128 | string | Successful haggle response | +| haggle_fail | 128 | string | Failed haggle response | ### Store Types **Inn (inn_night_cost > 0):** + - No products (144 bytes padding) - Provides rest/healing services - Nightly accommodation cost - Dialogue for innkeeper **Shop (inn_night_cost = 0):** + - Product inventory (up to 15 items) - Sells goods and equipment - Price modifier affects economy @@ -66,15 +70,17 @@ Binary database file that defines shops and inns with inventories, prices, merch ### Product Structure **ProductType Enum:** + - **1**: Weapon - Weapons and armor - **2**: Healing - Healing items (potions, etc.) - **3**: EditItem - Editable/modifiable equipment - **4**: MiscItem - Miscellaneous items **Product Format:** + - `(type: i16, item_id: i16)` per slot (4 bytes each) - Terminated by `type = 0` -- Max 15 products per shop (the game iterates `iVar4 < 0xf`) +- Max 15 products per shop - Slot index is the display order ### Price Modifier & Pricing Formulas @@ -134,6 +140,7 @@ Offset | Size | Field | Description ### Example Structures **Inn Record:** + ``` name: "Tavern Name" (32 bytes) inn_night_cost: 50 (i32) @@ -144,6 +151,7 @@ haggle_fail: "That's my best price!" (128 bytes) ``` **Shop Record:** + ``` name: "Weapon Shop" (32 bytes) inn_night_cost: 0 (i32) diff --git a/docs/files/CharacterInGame/WeaponItem.db.md b/docs/files/CharacterInGame/WeaponItem.db.md index 3224bf56..d5e92caa 100644 --- a/docs/files/CharacterInGame/WeaponItem.db.md +++ b/docs/files/CharacterInGame/WeaponItem.db.md @@ -1,50 +1,49 @@ # WeaponItem.db -`CharacterInGame/WeaponItem.db` defines weapons and armour for DISPEL®. +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + +`CharacterInGame/WeaponItem.db` defines weapons and armour. ## Format - Header: 4-byte little-endian `i32` record count. -- Records: `record_count` fixed-size records of 284 bytes (`0x11c`). +- Records: `record_count` fixed-size records of 284 bytes. - Text: `name` and `description` are null-padded WINDOWS-1250 strings. The game loader reads the complete record block, then overwrites each record's -`weapon_item_id` word with its zero-based record index. The file's stored value -at that offset is therefore not a stable persistent ID. - -| Offset | Size | Type | Field | Meaning | -|---:|---:|---|---|---| -| `0x000` | 30 | string | `name` | Item name | -| `0x01e` | 202 | string | `description` | Item description | -| `0x0e8` | 4 | i32 | `base_price` | Shop value | -| `0x0ec` | 4 | i32 | `weapon_item_id` | Runtime ID; replaced with record index at load | -| `0x0f0` | 2 | i16 | `health_points` | HP bonus | -| `0x0f2` | 2 | i16 | `mana_points` | MP bonus | -| `0x0f4` | 2 | i16 | `strength` | Strength bonus | -| `0x0f6` | 2 | i16 | `agility` | Agility bonus | -| `0x0f8` | 2 | i16 | `wisdom` | Wisdom/magic bonus | -| `0x0fa` | 2 | i16 | `constitution` | Constitution bonus | -| `0x0fc` | 2 | i16 | `to_dodge` | Evasion bonus (`UNIK`) | -| `0x0fe` | 2 | i16 | `to_hit` | Accuracy bonus (`TRF`) | -| `0x100` | 2 | i16 | `attack` | Attack bonus | -| `0x102` | 2 | i16 | `defense` | Defence bonus | -| `0x104` | 2 | i16 | `magical_strength` | Magic-power bonus | -| `0x106` | 2 | i16 | `durability` | Item durability | -| `0x108` | 2 | i16 | `reserved_0x108` | Reserved; zero in shipped records | -| `0x10a` | 2 | i16 | `reserved_0x10a` | Reserved; zero in shipped records | -| `0x10c` | 2 | i16 | `req_strength` | Minimum strength | -| `0x10e` | 2 | i16 | `reserved_0x10e` | Reserved; zero in shipped records | -| `0x110` | 2 | i16 | `req_agility` | Minimum agility | -| `0x112` | 2 | i16 | `reserved_0x112` | Reserved; zero in shipped records | -| `0x114` | 2 | i16 | `req_wisdom` | Minimum wisdom | -| `0x116` | 2 | i16 | `reserved_0x116` | Reserved; zero in shipped records | -| `0x118` | 2 | i16 | `reserved_0x118` | Reserved; zero in shipped records | -| `0x11a` | 2 | i16 | `reserved_0x11a` | Reserved; zero in shipped records | +`weapon_item_id` word with its zero-based record index. The file's stored value at that offset is therefore not a stable +persistent ID. -The seven reserved words are serialized and copied into save-game inventory -records. No code path in the supplied pseudocode reads them, and all 87 -records in the supplied game database contain zero at those offsets. Preserve -their values when editing; their purpose is unknown. +| Offset | Size | Type | Field | Meaning | +|-------:|-----:|--------|--------------------|------------------------------------------------| +| 0 | 30 | string | `name` | Item name | +| 30 | 202 | string | `description` | Item description | +| 232 | 4 | i32 | `base_price` | Shop value | +| 236 | 4 | i32 | `weapon_item_id` | Runtime ID; replaced with record index at load | +| 240 | 2 | i16 | `health_points` | HP bonus | +| 242 | 2 | i16 | `mana_points` | MP bonus | +| 244 | 2 | i16 | `strength` | Strength bonus | +| 246 | 2 | i16 | `agility` | Agility bonus | +| 248 | 2 | i16 | `wisdom` | Wisdom/magic bonus | +| 250 | 2 | i16 | `constitution` | Constitution bonus | +| 252 | 2 | i16 | `to_dodge` | Evasion bonus (`UNIK`) | +| 254 | 2 | i16 | `to_hit` | Accuracy bonus (`TRF`) | +| 256 | 2 | i16 | `attack` | Attack bonus | +| 258 | 2 | i16 | `defense` | Defence bonus | +| 260 | 2 | i16 | `magical_strength` | Magic-power bonus | +| 262 | 2 | i16 | `durability` | Item durability | +| 264 | 2 | i16 | `reserved_0x108` | Reserved; zero by default | +| 266 | 2 | i16 | `reserved_0x10a` | Reserved; zero by default | +| 268 | 2 | i16 | `req_strength` | Minimum strength | +| 270 | 2 | i16 | `reserved_0x10e` | Reserved; zero by default | +| 272 | 2 | i16 | `req_agility` | Minimum agility | +| 274 | 2 | i16 | `reserved_0x112` | Reserved; zero by default | +| 276 | 2 | i16 | `req_wisdom` | Minimum wisdom | +| 278 | 2 | i16 | `reserved_0x116` | Reserved; zero by default | +| 280 | 2 | i16 | `reserved_0x118` | Reserved; zero by default | +| 282 | 2 | i16 | `reserved_0x11a` | Reserved; zero by default | -DISPEL® is a registered trademark. This project is not affiliated with, -endorsed by, or sponsored by the trademark owner. +The seven reserved words are serialized and copied into save-game inventory records. No observed reader touches +them, and all known records contain zero at those offsets. Preserve their values when editing; their purpose is +unknown. diff --git a/docs/files/Event.ini.md b/docs/files/Event.ini.md index 9af0cb3b..b4fa1d63 100644 --- a/docs/files/Event.ini.md +++ b/docs/files/Event.ini.md @@ -1,17 +1,20 @@ # Event.ini Documentation +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information ### Overview -Text file that defines event scripts with execution conditions, prerequisites, and repetition limits for the game's event system. +Text file that defines event scripts with execution conditions, prerequisites, and repetition limits for the game's +event system. ### File Structure **Location**: `Event.ini` **Encoding**: EUC-KR (Korean character encoding) **Format**: CSV (Comma-Separated Values) with comments -**Total Entries**: 2,251 event mappings ### Format Specification @@ -26,13 +29,13 @@ event_id,required_event_id,event_type,script_filename,counter ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| event_id | i32 | Unique event identifier (0-2250+) | -| required_event_id | i32 | Prerequisite event ID that must be completed first | -| event_type | i32 | Execution condition type (0-8) | -| script_filename | string | Script filename or "null" for no script | -| counter | i32 | Execution limit (0 = unlimited, N = max executions) | +| Field | Type | Description | +|-------------------|--------|-----------------------------------------------------| +| event_id | i32 | Unique event identifier | +| required_event_id | i32 | Prerequisite event ID that must be completed first | +| event_type | i32 | Execution condition type (0-8) | +| script_filename | string | Script filename or "null" for no script | +| counter | i32 | Execution limit (0 = unlimited, N = max executions) | ### Event Type System @@ -55,12 +58,14 @@ The file includes a detailed comment header explaining the event type system: ### Event Type Details **Unconditional Execution (Types 0, 1, 2):** + - Execute regardless of previous event status - Type 0: Execute once - Type 1: Execute N times (uses counter) - Type 2: Execute unconditionally **Conditional Execution (Types 3-8):** + - Execution depends on previous event status - Types 3-5: Execute when previous event unsatisfied - Types 6-8: Execute when previous event satisfied @@ -92,97 +97,50 @@ The file includes a detailed comment header explaining the event type system: ### Technical Details **Encoding**: EUC-KR (Extended Unix Code Korea) + - Supports Korean characters in comments - Requires proper encoding handling for reading/writing **File Processing**: + - Comments (lines starting with ";") are ignored - Empty lines are skipped - CSV format with comma delimiter - "null" literal used for missing script filenames -**Database Integration**: -- Processed by `Event` struct in the codebase -- Uses `EventType` enum for type-safe event types +### Database Integration + +- Processed by the `Event` struct in `src/references/event_ini.rs` +- Uses the `EventType` enum from `src/references/enums.rs` for type-safe event types - Stored in database with all field mappings - Referenced by other game systems (NPC, Extra objects) -### Event Type Enum - -The codebase defines a type-safe enum for event types: - -```rust -pub enum EventType { - ExecuteOnce, // Type 0 - ExecuteNTimes, // Type 1 - ExecuteUnconditionally, // Type 2 - ExecuteOnceIfFailed, // Type 3 - ExecuteNTimesIfFailed, // Type 4 - ContinueIfFailed, // Type 5 - ExecuteOnceIfSucceeded, // Type 6 - ExecuteNTimesIfSucceeded, // Type 7 - ContinueIfSucceeded, // Type 8 - Unknown, // Fallback -} -``` - -### Usage in Game - -1. **Event System Initialization**: Game loads event mappings from Event.ini -2. **Quest Progression**: Events trigger based on completion status -3. **Script Execution**: Runs associated SCR files when conditions met -4. **State Management**: Tracks event completion with counters -5. **Prerequisite Checking**: Validates previous event requirements - ### Event Chaining The system supports complex event sequences: + - **Linear Progression**: Event A → Event B → Event C - **Conditional Branching**: Different paths based on success/failure - **Parallel Events**: Multiple independent event chains - **Repeating Events**: Limited or unlimited execution cycles -### File Characteristics - -- **Entry Count**: 2,251 event mappings -- **ID Range**: 0-2250+ (some gaps in sequence) -- **Comment Organization**: Logical grouping by function -- **Encoding**: EUC-KR with Korean comments -- **Format**: Strict CSV structure - ### Notes -- File uses Windows-style line endings (\r\n) -- Comments provide detailed explanations in Korean +- File uses Windows-style line endings (`\r\n`) +- Comments provide detailed explanations - Event system forms core of game progression mechanics - Integrated with multiple game subsystems -- **No copyrighted game content** is reproduced or distributed - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, script files, or proprietary assets. All references to event systems are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -## Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any script files or game content -- Focuses on **technical organization and event system design** -- Uses **generic examples** of event structures -- Maintains **nominal fair use** for trademark references ## Extractor -An extractor is available in `src/references/event_ini.rs` to parse this file format. +The file is parsed by the `Event` struct in `src/references/event_ini.rs`, which implements the `Extractor` trait. ### How to Run ```bash # Extract Event.ini to JSON -cargo run -- extract -i "fixtures/Dispel/Event.ini" +cargo run -- extract -i "Event.ini" # Import to SQLite database -cargo run -- database import "fixtures/Dispel/" "database.sqlite" +cargo run -- database import "path/to/Dispel/" "database.sqlite" ``` diff --git a/docs/files/Extra.ini.md b/docs/files/Extra.ini.md index eefce5f2..5685a319 100644 --- a/docs/files/Extra.ini.md +++ b/docs/files/Extra.ini.md @@ -1,5 +1,8 @@ # Extra.ini Documentation +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information ### Overview @@ -11,7 +14,6 @@ Text file that defines interactive-object definitions with visual assets, activa **Location**: `Extra.ini` **Encoding**: EUC-KR (Korean character encoding) **Format**: CSV (Comma-Separated Values) with comments -**Total Entries**: 182 interactive object definitions ### Format Specification @@ -19,31 +21,27 @@ Text file that defines interactive-object definitions with visual assets, activa ; Comment line explaining field structure id,sprite_filename,activation_sprite_frame_mode,description 0,null,0,null -1,object1.spr,0,Object description -2,object2.spr,1,Special object description +1,object1.spr,0,Storage object +2,object2.spr,1,Interactive object ... ``` ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| id | i32 | Unique interactive object identifier. | -| sprite_filename | string | SPR filename or "null" | -| activation_sprite_frame_mode | i32 | Selects the sprite frame used after activation for the object handlers that use this setting. | -| description | string | Editor-facing description or "null". The executable loader does not copy this fourth column into its runtime definition table. | - -### Activation-frame mode - -The game loads this field into the runtime object definition and copies it to -each placed instance. In the observed activation path for object handlers 5, 6, -and 8, values greater than `1` choose activated sprite frame `1`; values `0` -and `1` choose frame `0`. The shipped file contains `0`, `1`, and `2`. +| Field | Type | Description | +|------------------------------|--------|---------------------------------------------------------------------------------------------------------------| +| id | i32 | Unique interactive object identifier. | +| sprite_filename | string | SPR filename or "null". | +| activation_sprite_frame_mode | i32 | Selects the sprite frame used after activation for the object handlers that use this setting. | +| description | string | Editor-facing description or "null". This column is not loaded by the game into its runtime definition table. | -The executable does not support interpreting this column as a standard/special -or quest flag. +### Activation-Frame Mode +Every placed instance using the definition carries a copy of this field. For +object handlers 5, 6, and 8, values greater than `1` choose activated sprite +frame `1`; values `0` and `1` choose frame `0`. Observed files contain `0`, +`1`, and `2`, so this field is not a boolean or a quest flag. -### Text-format details +### Text-Format Details - **"null"**: Literal string indicating no sprite or description - **;**: Lines starting with semicolon are comments @@ -67,17 +65,20 @@ or quest flag. ### Technical Details **Encoding**: EUC-KR (Extended Unix Code Korea) + - Supports Korean characters in descriptions - Requires proper encoding handling for reading/writing **File Processing**: + - Comments (lines starting with ";") are ignored - Empty lines are skipped - CSV format with comma delimiter - "null" literal used for missing fields -**Database Integration**: -- Processed by `Extra` struct in the codebase +### Database Integration + +- Processed by the `Extra` struct in `src/references/extra_ini.rs` - Stored in database with all field mappings - Linked to object placement files (Ext*.ref) - Referenced by interaction and puzzle systems @@ -88,14 +89,14 @@ The codebase defines the structure for interactive objects: ```rust pub struct Extra { - id: i32, // Object ID - sprite_filename: Option, // Visual asset - activation_sprite_frame_mode: i32, // Activation-frame selector (0, 1, or 2 in shipped data) - description: Option, // Object description + pub id: i32, // Object ID + pub sprite_filename: Option, // Visual asset + pub activation_sprite_frame_mode: i32, // Activation-frame selector + pub description: Option, // Object description } ``` -### Usage in Game +### Usage 1. **Environment Interaction**: Defines objects players can interact with 2. **Visual Mapping**: Links object IDs to sprite files @@ -103,64 +104,22 @@ pub struct Extra { 4. **Puzzle Systems**: Objects used in environmental puzzles 5. **Map Placement**: Referenced by Ext*.ref placement files -### Object Function Analysis - -**Container System:** -- IDs 1-5: Various chest and storage types -- Linked to loot and inventory systems -- Standard interaction patterns - -**Navigation System:** -- Doors, ladders, ropes for movement -- Teleportation objects for fast travel -- Special transition objects - -**Information System:** -- Signs and markers provide guidance -- Shop signs indicate services -- Quest objects provide story context - -### File Characteristics - -- **Fixture entry count**: 151 object definitions -- **Fixture ID range**: 0-150 (ID 0 = default/null entry) -- **Activation-frame modes**: Values 0, 1, and 2 occur in the shipped data -- **Comment Organization**: Logical grouping by object type -- **Encoding**: EUC-KR with Korean descriptions - ### Notes -- File uses Windows-style line endings (\r\n) -- Comments are in Polish and Korean (mixed encoding) +- File uses Windows-style line endings (`\r\n`) - Descriptions use EUC-KR encoding for international characters - Integrated with map placement and interaction systems -- **No copyrighted game content** is reproduced or distributed - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game assets, sprite files, or proprietary artwork. All references to interactive objects are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -## Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any SPR/SPX files or game artwork -- Focuses on **technical organization and interaction systems** -- Uses **generic examples** of object structures -- Maintains **nominal fair use** for trademark references ## Extractor -An extractor is available in `src/references/extra_ini.rs` to parse this file format. +The file is parsed by the `Extra` struct in `src/references/extra_ini.rs`, which implements the `Extractor` trait. ### How to Run ```bash # Extract Extra.ini to JSON -cargo run -- extract -i "fixtures/Dispel/Extra.ini" +cargo run -- extract -i "Extra.ini" # Import to SQLite database -cargo run -- database import "fixtures/Dispel/" "database.sqlite" +cargo run -- database import "path/to/Dispel/" "database.sqlite" ``` diff --git a/docs/files/ExtraInGame/Extra.ref.md b/docs/files/ExtraInGame/Extra.ref.md index 02622748..f378e591 100644 --- a/docs/files/ExtraInGame/Extra.ref.md +++ b/docs/files/ExtraInGame/Extra.ref.md @@ -1,10 +1,14 @@ # ExtraInGame/Ext*.ref Documentation +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information ### Overview -Binary files that define the placement and configuration of interactive objects (chests, doors, signs, altars, magic items) on specific maps. Each map has its own `Ext*.ref` file containing all interactive elements for that map. +Binary files that define the placement and configuration of interactive objects (chests, doors, signs, altars, magic +items) on specific maps. Each map has its own `Ext*.ref` file containing all interactive elements for that map. ### File Structure @@ -21,7 +25,7 @@ Binary files that define the placement and configuration of interactive objects - record_count: i32 (number of records) [Record 1] -- map_object_id: u16 (map-local ID; the engine identifies it as `700 + map_object_id`) +- map_object_id: u16 (map-local ID; exposed as `700 + map_object_id` in the tile/object system) - unknown1: u8 (padding, always 0) - extra_definition_id: u8 (links to Extra.ini) - object_name: 32 bytes (WINDOWS-1250, null-padded) @@ -75,172 +79,155 @@ Binary files that define the placement and configuration of interactive objects ### Field Definitions -### Reverse-engineering notes - -The game copies each 184-byte record into its interactive-object runtime -structure unchanged. The following meanings come from that runtime code rather -than from value-frequency guesses: - -- `map_object_id` is used to identify the object as `700 + map_object_id` in - the map-object grid. -- `interaction_state` is preserved across reloads and is changed by the object - interaction handlers. For chests it selects the closed/open sprite sequence. -- `requires_key` chooses the handler path that validates the three inclusive - requirement ranges. A range starting with `9999` is disabled. -- The engine grants `gold_amount` and processes three `(item, count)` loot - pairs. The first pair is `loot_item`/`loot_item_count`; the next two are the - `additional_loot_*` fields. -- `footprint_width`, `footprint_height`, and `footprint_orientation` determine - which map cells the object occupies. `interaction_range` limits activation - distance. - -The 28-byte `additional_loot_2_count_and_config` field still contains mixed -configuration: its first `i32` is the third loot quantity, its second `i32` -selects the loot-delivery mode, and its final `i32` is a remaining-use counter. -The intervening 16 bytes vary by object type and remain deliberately opaque. +### Field semantics + +- `map_object_id` is exposed to the tile/object system as `700 + map_object_id`. +- `interaction_state` is preserved across reloads; it selects the closed/open sprite sequence for chest-type objects. +- `requires_key` enables validation of the three inclusive requirement ranges. A range starting with `9999` is disabled. +- `gold_amount` awards gold, and three `(item, count)` loot pairs are processed: `loot_item`/`loot_item_count`, then the + two + `additional_loot_*` pairs. +- `footprint_width`, `footprint_height`, and `footprint_orientation` determine which map cells the object occupies. + `interaction_range` limits activation distance. + +The 28-byte `additional_loot_2_count_and_config` field contains mixed configuration: its first `i32` is the third loot +quantity, its second `i32` +selects the loot-delivery mode, and its final `i32` is a remaining-use counter. The intervening 16 bytes vary by object +type. #### Core Identification -| Field | Type | Description | -|-------|------|-------------| -| record_index | i32 | Zero-based parser-derived record position; not stored in the file | -| map_object_id | u16 | Map-local object ID. The engine refers to the object as `700 + map_object_id`. | -| extra_definition_id | u8 | ID of the visual/behavior definition in `Extra.ini`. | -| object_name | String (32 bytes) | Author-facing object label, WINDOWS-1250 encoded and null-padded. | +| Field | Type | Description | +|---------------------|-------------------|--------------------------------------------------------------------------------| +| record_index | i32 | Zero-based parser-derived record position; not stored in the file | +| map_object_id | u16 | Map-local object ID. The engine refers to the object as `700 + map_object_id`. | +| extra_definition_id | u8 | ID of the visual/behavior definition in `Extra.ini`. | +| object_name | String (32 bytes) | Author-facing object label, WINDOWS-1250 encoded and null-padded. | #### Position and Orientation -| Field | Type | Description | -|-------|------|-------------| -| map_x | i32 | Horizontal tile-grid coordinate. | -| map_y | i32 | Vertical tile-grid coordinate. | -| direction | u8 | Sprite-facing/frame index; chest rendering uses it with `interaction_state`. | +| Field | Type | Description | +|-----------|------|------------------------------------------------------------------------------| +| map_x | i32 | Horizontal tile-grid coordinate. | +| map_y | i32 | Vertical tile-grid coordinate. | +| direction | u8 | Sprite-facing/frame index; chest rendering uses it with `interaction_state`. | #### Object Classification -| Field | Type | Description | -|-------|------|-------------| -| object_type | u8 | Object category enum | -| activation_effect_id | u8 | Index passed to the activation-effect dispatcher (observed: 0, 10). | -| interaction_range | u8 | Maximum tile distance at which the engine allows activation. | -| is_quest_element | i32 | Quest-related interaction flag; the engine uses it after a successful requirement check. | +| Field | Type | Description | +|----------------------|------|---------------------------------------------------------------------------| +| object_type | u8 | Object category enum | +| activation_effect_id | u8 | Index passed to the activation-effect dispatcher (observed: 0, 10). | +| interaction_range | u8 | Maximum tile distance at which the engine allows activation. | +| is_quest_element | i32 | Quest-related interaction flag; set after a successful requirement check. | #### Runtime and Rendering Controls -| Field | Type | Description | -|-------|------|-------------| -| post_activation_tile_flag | BooleanFlag | Enables the post-activation tile flag written to the map-object grid. | -| post_activation_footprint_mode | BooleanFlag | Selects the footprint update mode after activation. | -| preserve_final_sprite_frame | i32 | Non-zero keeps the terminal sprite frame after interaction. | -| alternate_render_mode | BooleanFlag | Selects the alternate renderer. | -| active_overlay_enabled | BooleanFlag | Enables the active-object overlay render path. | -| map_object_active | BooleanFlag | Makes the object participate in the map-object grid and update loop. | -| activation_effect_reserved | u8 | Reserved byte adjacent to the effect ID; preserve verbatim. | +| Field | Type | Description | +|--------------------------------|-------------|-----------------------------------------------------------------------| +| post_activation_tile_flag | BooleanFlag | Enables the post-activation tile flag written to the map-object grid. | +| post_activation_footprint_mode | BooleanFlag | Selects the footprint update mode after activation. | +| preserve_final_sprite_frame | i32 | Non-zero keeps the terminal sprite frame after interaction. | +| alternate_render_mode | BooleanFlag | Selects the alternate renderer. | +| active_overlay_enabled | BooleanFlag | Enables the active-object overlay render path. | +| map_object_active | BooleanFlag | Makes the object participate in the map-object grid and update loop. | +| activation_effect_reserved | u8 | Reserved byte adjacent to the effect ID; preserve verbatim. | #### Container State -| Field | Type | Description | -|-------|------|-------------| -| interaction_state | i32 | Current activation/open state (0 before use, 1 after use) | -| requires_key | i32 | Whether interaction validates the configured key/item ranges | +| Field | Type | Description | +|-------------------|------|--------------------------------------------------------------| +| interaction_state | i32 | Current activation/open state (0 before use, 1 after use) | +| requires_key | i32 | Whether interaction validates the configured key/item ranges | #### Key Requirements -| Field | Type | Description | -|-------|------|-------------| -| required_item_id | u8 | Lower bound key ID to interact | -| required_item_type_id | u8 | Category of lower bound key | -| required_item_id2 | u8 | Upper bound key ID to interact | -| required_item_type_id2 | u8 | Category of upper bound key | +| Field | Type | Description | +|------------------------|------|--------------------------------| +| required_item_id | u8 | Lower bound key ID to interact | +| required_item_type_id | u8 | Category of lower bound key | +| required_item_id2 | u8 | Upper bound key ID to interact | +| required_item_type_id2 | u8 | Category of upper bound key | #### Contents -| Field | Type | Description | -|-------|------|-------------| -| gold_amount | i32 | Gold awarded by the normal loot path. | -| loot_item | packed u16 | First static loot item (`low byte = item ID`, `high byte = item type`). | -| loot_item_count | i32 | Quantity of `loot_item`. | -| additional_loot_1 / additional_loot_2 | i32 | Further loot item IDs; `9999` means unused. | +| Field | Type | Description | +|---------------------------------------|------------|-------------------------------------------------------------------------| +| gold_amount | i32 | Gold awarded by the normal loot path. | +| loot_item | packed u16 | First static loot item (`low byte = item ID`, `high byte = item type`). | +| loot_item_count | i32 | Quantity of `loot_item`. | +| additional_loot_1 / additional_loot_2 | i32 | Further loot item IDs; `9999` means unused. | #### Event Triggers -| Field | Type | Description | -|-------|------|-------------| -| interaction_event_id | i32 | `Event.ini` logic executed after interaction; zero means none. | -| interaction_message_id | i32 | `Message.scr` entry used by message/sign-style objects; zero means none. | +| Field | Type | Description | +|------------------------|------|--------------------------------------------------------------------------| +| interaction_event_id | i32 | `Event.ini` logic executed after interaction; zero means none. | +| interaction_message_id | i32 | `Message.scr` entry used by message/sign-style objects; zero means none. | ### Enumerations #### Object Type (object_type) -| Value | Name | Description | -|-------|------|-------------| -| 0 | Chest | Treasure container | -| 2 | Door | Passage barrier | -| 4 | Sign | Text display object | -| 5 | Altar | Religious/ritual object | -| 6 | Interactive | General interactive element | -| 7 | Magic | Magical object | +| Value | Name | Description | +|-------|-------------|-----------------------------| +| 0 | Chest | Treasure container | +| 2 | Door | Passage barrier | +| 4 | Sign | Text display object | +| 5 | Altar | Religious/ritual object | +| 6 | Interactive | General interactive element | +| 7 | Magic | Magical object | #### Activation Effect ID (activation_effect_id) -| Value | Name | Description | -|-------|------|-------------| -| 0 | None | No activation effect dispatched. | -| 10 | Effect10 | Effect index 10; the exact effect asset is not yet identified. | +| Value | Name | Description | +|-------|----------|----------------------------------------------------------------| +| 0 | None | No activation effect dispatched. | +| 10 | Effect10 | Effect index 10; the exact effect asset is not yet identified. | #### Item Type ID (required_item_type_id, loot_item_type_id) -| Value | Name | Description | -|-------|------|-------------| -| 0 | Weapon | Weapon category | -| 1 | Armor | Armor category | -| 2 | Heal | Healing item category | -| 3 | Misc | Miscellaneous item category | -| 4 | Edit | Edit item category | -| 5 | Event | Event item category | -| 6 | Extra | Extra item category | +| Value | Name | Description | +|-------|--------|-----------------------------| +| 0 | Weapon | Weapon category | +| 1 | Armor | Armor category | +| 2 | Heal | Healing item category | +| 3 | Misc | Miscellaneous item category | +| 4 | Edit | Edit item category | +| 5 | Event | Event item category | +| 6 | Extra | Extra item category | ### Interactive Element Types -| Value | Description | -|-------|-------------| -| 0 | Pillars (e.g., Gods garden) | -| 1 | Standard interactive | -| 2 | Unknown variant | -| 3 | Special altars (e.g., Vera altar) | +| Value | Description | +|-------|-----------------------------------| +| 0 | Pillars (e.g., Gods garden) | +| 1 | Standard interactive | +| 2 | Unknown variant | +| 3 | Special altars (e.g., Vera altar) | ### File Purpose -These files define interactive object placements with exact coordinates, requirements, contents, and behaviors. Used for populating maps with chests, doors, signs, and other interactive elements. Each map in the game has a corresponding `Ext*.ref` file. - -### Usage in Game - -1. **Map Loading**: When a map loads, the game reads the corresponding `Ext*.ref` file -2. **Object Placement**: Objects are placed at specified x/y coordinates -3. **Interaction Handling**: Game checks requirements (keys, items) before allowing interaction -4. **Event Triggering**: Interaction triggers events from `Event.ini` -5. **Content Distribution**: Containers provide gold and items to players -6. **Quest Integration**: Quest-related interactions can set `is_quest_element` after a successful requirement check. +These files define interactive object placements with exact coordinates, requirements, contents, and behaviors. Used for +populating maps with chests, doors, signs, and other interactive elements. Each map in the game has a corresponding +`Ext*.ref` file. ### Cross-References -| Field | References | -|-------|------------| -| extra_definition_id | `Extra.ini` (visual/behavior definition) | -| interaction_event_id | `Event.ini` (event logic) | -| interaction_message_id | `Message.scr` (text display) | -| required_item_type_id | Item type enumeration | -| loot_item_type_id | Item type enumeration | +| Field | References | +|------------------------|------------------------------------------| +| extra_definition_id | `Extra.ini` (visual/behavior definition) | +| interaction_event_id | `Event.ini` (event logic) | +| interaction_message_id | `Message.scr` (text display) | +| required_item_type_id | Item type enumeration | +| loot_item_type_id | Item type enumeration | ### Technical Details -**Endianness**: Little-Endian throughout -**Record Count**: Stored as first 4 bytes (i32) +**Endianness**: Little-Endian throughout **Record Count**: Stored as first 4 bytes (i32) **Record Size**: Fixed 184 bytes (46 × i32 equivalent) -**File Size**: 4 + (record_count × 184) bytes -**Text Encoding**: WINDOWS-1250 for name fields -**Padding**: Extensive padding with known patterns (zeros, 205/0xCD bytes) +**File Size**: 4 + (record_count × 184) bytes **Text Encoding**: WINDOWS-1250 for name fields **Padding**: Extensive +padding with known patterns (zeros, 205 bytes) ### File Processing @@ -265,27 +252,10 @@ These files define interactive object placements with exact coordinates, require - File uses little-endian byte order throughout - Name fields are null-terminated WINDOWS-1250 strings -- Padding bytes often contain 0xCD (205) or 0x00 patterns -- The `closed` field primarily applies to chest-type objects +- Padding bytes are often filled with the byte value 205 or zero - Sign objects use `interaction_message_id` to display text from `Message.scr`. - Door objects may require specific keys defined by `required_item_*` fields -- Magic objects (type 7) likely represent spell-related interactables -- The dual key system (`required_item_id`/`required_item_id2`) may support key ranges or multiple key types - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, assets, or proprietary data. All references are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -## Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any game content or assets -- Uses **generic descriptions** of objects and behaviors -- Focuses on **technical organization**, not creative content -- Maintains **nominal fair use** for trademark references +- The dual key system (`required_item_id`/`required_item_id2`) supports key ranges or multiple key types ## Extractor diff --git a/docs/files/ExtraInGame/Message.scr.md b/docs/files/ExtraInGame/Message.scr.md index 440f5708..f84d493a 100644 --- a/docs/files/ExtraInGame/Message.scr.md +++ b/docs/files/ExtraInGame/Message.scr.md @@ -1,6 +1,10 @@ -# Message.scr - UI Text Messages +# Message.scr — UI Text Messages + +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. ## File Information + - **Location**: `ExtraInGame/Message.scr` - **Format**: Pipe-delimited text - **Encoding**: WINDOWS-1250 @@ -9,11 +13,13 @@ ## File Structure ### File Format + - Lines starting with `;` are comments - Pipe-delimited format: `field1|field2|field3|field4` - Empty lines are ignored ### Record Structure + - `id`: i32 - Unique message identifier - `line1`: String - First text line (top) - `line2`: String - Second text line (middle) @@ -22,24 +28,29 @@ ## Field Definitions ### id + - Unique message identifier - References from game objects and events - Used for displaying specific messages ### line1, line2, line3 + - Three lines of text for UI display - Each line can be up to 255 characters - Lines are displayed vertically stacked - Empty lines use "null" literal ## Special Values + - `"null"` literal for empty text lines - Lines starting with `;` are comments - Pipe (`|`) delimiter between fields - Maximum 3 lines per message ## File Purpose + Stores multi-line text messages for UI elements such as: + - Signposts and plaques - System notifications - Environmental storytelling @@ -49,10 +60,12 @@ Stores multi-line text messages for UI elements such as: ## Message Types ### Signposts + - Location descriptions - Area names and information ## Implementation + - **Rust Module**: `src/references/message_scr.rs` - **Extractor**: `Message` struct implementing `Extractor` trait - **Data Structure**: `Message` with ID and three text lines @@ -61,11 +74,13 @@ Stores multi-line text messages for UI elements such as: ## Example Usage ### Extract and display messages: + ```bash cargo run -- extract -i "Dispel/ExtraInGame/Message.scr" ``` ### Format Example + ``` ; Welcome message 1|Welcome|to the|town @@ -78,12 +93,14 @@ cargo run -- extract -i "Dispel/ExtraInGame/Message.scr" ``` ## Display System + - Messages are displayed in UI panels - Three-line vertical layout - Centered or aligned based on context - WINDOWS-1250 character encoding support ## Related Files + - `Quest.scr` - Quest journal entries - `Extra.ini` - Object definitions referencing messages - Game scripts triggering message display diff --git a/docs/files/ExtraInGame/Quest.scr.md b/docs/files/ExtraInGame/Quest.scr.md index de1a3dfe..b5e7ede1 100644 --- a/docs/files/ExtraInGame/Quest.scr.md +++ b/docs/files/ExtraInGame/Quest.scr.md @@ -1,6 +1,10 @@ -# Quest.scr - Quest Journal Entries +# Quest.scr — Quest Journal Entries + +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. ## File Information + - **Location**: `ExtraInGame/Quest.scr` - **Format**: Pipe-delimited text - **Encoding**: WINDOWS-1250 @@ -9,11 +13,13 @@ ## File Structure ### File Format + - Lines starting with `;` are comments - Pipe-delimited format: `field1|field2|field3|field4` - Empty lines are ignored ### Record Structure + - `id`: i32 - Unique quest identifier - `type`: i32 - Quest category - `title`: String - Quest title/name @@ -22,25 +28,29 @@ ## Field Definitions ### id + - Unique quest identifier - References from event system - Used for quest tracking ### type + - Quest category identifier - Determines quest classification - Values: - - `0`: Main quests (primary story line) - - `1`: Side quests (optional content) - - `2`: Traders journal (commerce-related) + - `0`: Main quests (primary story line) + - `1`: Side quests (optional content) + - `2`: Traders journal (commerce-related) ### title + - Quest title/name - Displayed in quest journal - Short summary of quest objective - "null" for empty titles ### description + - Quest description/text - Detailed quest information - Objectives and requirements @@ -49,12 +59,14 @@ ## Quest Types ### Main Quests (type=0) + - Primary story line - Required for game completion - Major plot points - Critical path quests ### Side Quests (type=1) + - Optional content - Additional rewards - World exploration @@ -62,6 +74,7 @@ - Lore expansion ### Traders Journal (type=2) + - Commerce-related quests - Trading missions - Economic activities @@ -69,13 +82,16 @@ - Supply chain quests ## Special Values + - `"null"` literal for missing title/description - Lines starting with `;` are comments - Pipe (`|`) delimiter between fields - Empty lines ignored ## File Purpose + Defines all quests with categories, titles, and descriptions. Used for: + - Quest journal system - Quest tracking and progression - Player objectives @@ -84,6 +100,7 @@ Defines all quests with categories, titles, and descriptions. Used for: - Linked to event system for completion ## Implementation + - **Rust Module**: `src/references/quest_scr.rs` - **Extractor**: `Quest` struct implementing `Extractor` trait - **Data Structure**: `Quest` with ID, type, title, and description @@ -92,11 +109,13 @@ Defines all quests with categories, titles, and descriptions. Used for: ## Example Usage ### Extract and display quests: + ```bash cargo run -- extract -i "fixtures/Dispel/ExtraInGame/Quest.scr" ``` ### Format Example + ``` ; Main quests 1|0|Main Quest|Defeat the Dark Lord diff --git a/docs/files/MagicInGame/Magic.db.md b/docs/files/MagicInGame/Magic.db.md index bee0cc07..e5525251 100644 --- a/docs/files/MagicInGame/Magic.db.md +++ b/docs/files/MagicInGame/Magic.db.md @@ -1,4 +1,7 @@ -# Magic.db - Spell Database +# Magic.db — Spell Database + +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. ## File Information @@ -11,43 +14,42 @@ All fields are little-endian `u32`; the record ID is its zero-based position. -| Offset | Field | Meaning | -|---------------:|--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| -| `0x00` | `enabled` | Spell availability flag | -| `0x04` | `effect_visual_blends_with_background` | `0`=Off, `1`=On — uses blended rendering for the initial spell visual instead of direct blitting | -| `0x08` | `base_damage` | Base damage used by the spell-damage calculation | -| `0x0C` | `base_success_rate` | Base casting-success chance before skill adjustment | -| `0x10` | `mana_cost` | Base mana cost before skill reduction; effective cost is at least 5 | -| `0x14`, `0x18` | `reserved_0x14`, `reserved_0x18` | Reserved words; zero in shipped `Magic.db` | -| `0x1C` | `effect_animation_repeats` | `0`=Off, `1`=On — repeats the target-effect animation while the target remains valid | -| `0x20` | `range` | Maximum target distance checked by casting code | -| `0x24` | `reserved_0x24` | Reserved word; zero in shipped `Magic.db` | -| `0x28` | `cast_duration` | Maximum casting/action progress counter | -| `0x2C` | `animation_data_index` | Index into the animation data table, resolved to a pointer by the game loader after reading the file | -| `0x30–0x38` | `effect_value`, `effect_type`, `effect_modifier` | Effect configuration; exact semantics are not yet established | -| `0x3C` | `reserved_0x3c` | Reserved word; zero in shipped `Magic.db` | -| `0x40` | `magic_type` | Magic type (0=Magic, 1=LightMagic, 2=BlackMagic) — selects which character magic-skill attribute drives damage, success, and mana-cost calculations | -| `0x44` | `target_animation_blends_with_background` | `0`=Off, `1`=On — uses blended rendering for the target animation instead of direct blitting | -| `0x48` | `animation_set_id` | Cast-animation set | -| `0x4C` | `effect_visual_id` | Visual/projectile mapping selected when casting | -| `0x50` | `icon_id` | UI icon ID (inferred from its use as a UI-facing ID) | -| `0x54` | `targeting_mode` | Targeting-mode configuration; exact value meanings need confirmation | - -## Confirmed runtime behavior - -- The game calculates effective mana cost from `mana_cost` and the caster's magic type skill, clamped to a minimum of 5. -- It calculates effective success chance from `base_success_rate` and that same skill category. -- `cast_duration` controls the progress limit used by casting/action state. +| Offset | Field | Meaning | +|-------:|--------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| 0 | `enabled` | Spell availability flag | +| 4 | `effect_visual_blends_with_background` | `0`=Off, `1`=On — uses blended rendering for the initial spell visual instead of direct blitting | +| 8 | `base_damage` | Base damage used by the spell-damage calculation | +| 12 | `base_success_rate` | Base casting-success chance before skill adjustment | +| 16 | `mana_cost` | Base mana cost before skill reduction; effective cost is at least 5 | +| 20, 24 | `reserved_0x14`, `reserved_0x18` | Reserved words; zero by default | +| 28 | `effect_animation_repeats` | `0`=Off, `1`=On — repeats the target-effect animation while the target remains valid | +| 32 | `range` | Maximum target distance checked by casting code | +| 36 | `reserved_0x24` | Reserved word; zero by default | +| 40 | `cast_duration` | Maximum casting/action progress counter | +| 44 | `animation_data_index` | Index into the animation data table, resolved to a pointer after file load | +| 48–56 | `effect_value`, `effect_type`, `effect_modifier` | Effect configuration; exact semantics are not yet established | +| 60 | `reserved_0x3c` | Reserved word; zero by default | +| 64 | `magic_type` | Magic type (0=Magic, 1=LightMagic, 2=BlackMagic) — selects which character magic-skill attribute drives damage, success, and mana-cost calculations | +| 68 | `target_animation_blends_with_background` | `0`=Off, `1`=On — uses blended rendering for the target animation instead of direct blitting | +| 72 | `animation_set_id` | Cast-animation set | +| 76 | `effect_visual_id` | Visual/projectile mapping selected when casting | +| 80 | `icon_id` | UI icon ID (inferred from its use as a UI-facing ID) | +| 84 | `targeting_mode` | Targeting-mode configuration; exact value meanings need confirmation | + +## Field semantics + +- Effective mana cost is derived from `mana_cost` and the caster's magic type skill, clamped to a minimum of 5. +- Effective success chance is derived from `base_success_rate` and that same skill category. +- `cast_duration` controls the progress limit used by the casting/action state. - `effect_visual_id` selects the mapping used to create the spell visual or projectile. -- The `0x04` and `0x44` flags select the renderer's palette-blending path; clear values use direct pixel copies. +- The flags at offsets 4 and 68 select the renderer's palette-blending path; clear values use direct pixel copies. - `effect_animation_repeats` keeps the target-effect animation alive after its last frame. -- `animation_data_index` (offset `0x2C`) is dereferenced as a pointer by the combat engine after the game loader patches - it at runtime. +- `animation_data_index` (offset 44) is resolved to a pointer after the file is loaded. ## File Purpose -Defines spell combat parameters, cast timing, and visual configuration. Effect-configuration words at offsets -`0x30–0x38` remain intentionally offset-named until their runtime behavior is confirmed. +Defines spell combat parameters, cast timing, and visual configuration. Effect-configuration words at offsets 48–56 +remain intentionally offset-named until their runtime behavior is confirmed. ## Implementation diff --git a/docs/files/Map/Map.btl.md b/docs/files/Map/Map.btl.md index 0c2f3b60..074fea73 100644 --- a/docs/files/Map/Map.btl.md +++ b/docs/files/Map/Map.btl.md @@ -1,125 +1,80 @@ -# cat1.btl - Dispel Game Building Tile Layer Format - -## File Information -- **Location**: `Map/*.btl` files -- **Format**: Binary tileset (same as .GTL) -- **Tile Size**: 32×32 pixels -- **Color Format**: RGB565 (2 bytes per pixel) -- **Tile Data Size**: 2048 bytes per tile (32×32×2) - -## File Structure - -### No Header -- Direct sequence of tile data -- No metadata or header information -- Tiles are stored sequentially - -### Tile Structure (2048 bytes) -- `pixel_data`: 1024 × RGB565 pixels (32×32 grid) -- Each pixel: 2 bytes in RGB565 format - -### RGB565 Color Format -- **5 bits**: Red channel (0-31) -- **6 bits**: Green channel (0-63) -- **5 bits**: Blue channel (0-31) -- **Storage**: u16 format `0xRRRRRGGGGGGBBBBB` - -## File Purpose - -### .BTL Files (Building Tile Layer) -- Structures and buildings -- Roofs and architectural elements -- Man-made objects -- Decorative elements -- Walls, doors, windows -- Interior furniture and fixtures - -## Isometric Properties -- **Rendered Width**: 62 pixels -- **Rendered Height**: 32 pixels -- **Shape**: Diamond-shaped mask for isometric projection -- **Transparency**: RGB(0,0,0) treated as transparent - -## Color Conversion -The RGB565 format is converted to RGB888 during extraction: -- 5-bit red → 8-bit red (scaled 0-31 to 0-255) -- 6-bit green → 8-bit green (scaled 0-63 to 0-255) -- 5-bit blue → 8-bit blue (scaled 0-31 to 0-255) - -## File Size Calculation -``` -Total tiles = file_size / 2048 -``` +# DISPEL® Building Tile Layer (`.btl`) -## Related Files -- `*.map` - Map files that reference these tilesets -- `*.gtl` - Ground tileset files +Raw tileset binary used for structures, roofs, and architectural elements. The format is identical to `.gtl`; only the +semantic role differs. -## Building Tile Files -- **Main maps**: `map1.btl`, `map2.btl`, `map3.btl` -- **Catacombs**: `cat1.btl`, `cat2.btl`, `cat3.btl`, `catp.btl` -- **Dungeons**: `dun01.btl` through `dun25.btl`, `final.btl` +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. -## Implementation -- **Rust Module**: `src/map/tileset.rs` -- **Extractor**: `extract` function (same as .GTL) -- **Renderer**: `plot_tileset_map` function -- **Tile Structure**: `Tile` struct with color data +## Quick Facts -## Example Usage +| Property | Value | +|---------------|---------------------------------| +| Location | `Map/*.btl` | +| Header | None | +| Tile size | 32×32 px, RGB565 | +| Tile data | 2048 bytes per tile (32×32 × 2) | +| Rendered size | 62×32 px isometric diamond | +| Transparency | RGB(0,0,0) = transparent | -### Extract building tiles to individual PNGs: -```bash -cargo run -- map tiles "fixtures/Dispel/Map/cat1.btl" --output "out/cat1-btl" -``` +## File Layout + +No header. The file is a contiguous sequence of 32×32 pixel tiles in RGB565 (little-endian u16) format. Tile count = +`file_size / 2048`. -### Generate building tileset atlas: -```bash -cargo run -- map atlas "fixtures/Dispel/Map/cat1.btl" cat1.btl.png +``` +┌──────────────────────┐ +│ TILE #0 │ +│ pixels: u16 × 1024 │ RGB565, little-endian +├──────────────────────┤ +│ TILE #1 │ +│ pixels: u16 × 1024 │ +├──────────────────────┤ +│ ... │ +├──────────────────────┤ +│ TILE #N │ +│ pixels: u16 × 1024 │ +└──────────────────────┘ ``` -### Render map using building tiles: -```bash -cargo run -- map render \ - --map "fixtures/Dispel/Map/cat1.map" \ - --btl "fixtures/Dispel/Map/cat1.btl" \ - --gtl "fixtures/Dispel/Map/cat1.gtl" \ - --output cat1.png +## RGB565 → RGB888 Conversion + +``` +red = bits 11–15 of pixel → scale 0–31 to 0–255 +green = bits 5–10 of pixel → scale 0–63 to 0–255 +blue = bits 0–4 of pixel → scale 0–31 to 0–255 ``` -## Building Construction -Buildings in Dispel are constructed using stacked BTL tiles: -- Multiple tiles stacked vertically to create walls -- Different tile patterns for different building types -- Roof tiles placed on top of wall stacks -- Doors and windows as special tile types +## Isometric Rendering -## Diamond Mask -The `create_mask` function generates a diamond-shaped mask for proper isometric tile rendering, creating the characteristic diamond shape of isometric tiles. +Tiles are projected as isometric diamonds (62×32 px). A diamond-shaped mask clips each tile so that corners blend +seamlessly. RGB (0,0,0) pixels are treated as transparent, allowing building tiles to layer over terrain. -## Transparency Handling -RGB(0,0,0) pixels are treated as transparent during rendering, allowing for proper layering of building tiles and creating complex structures. +## Relationship to `.map` Files -## Comparison with GTL Files +A `.map` file references BTL tile IDs in two places: -| Feature | BTL Files | GTL Files | -|--------------|----------|----------| -| **Purpose** | Buildings, structures | Terrain, natural features | -| **Content** | Walls, roofs, doors | Grass, water, paths | -| **Layer** | Above ground | Ground level | -| **Usage** | Stacked for 3D effect | Single layer | -| **Examples** | Houses, castles | Grass, dirt, water | +- **Tiled Objects block** — building stacks drawn from anchor points +- **Access-Ref block** — occlusion/access lookup per tile -## Extractor +BTL tile IDs inside the Tiled Objects block are stacked bottom-to-top; negative IDs are skipped during rendering. -An extractor is available in `src/map/tileset.rs` to parse this file format. +## Related Files + +| File | Role | +|---------|---------------------------------------------------| +| `*.gtl` | Ground tileset (same binary format, terrain role) | +| `*.map` | Map files that reference BTL tile IDs | + +## Implementation -### How to Run +- Parser: `extract()` in `src/map/tileset.rs` +- Atlas: `plot_tileset_map()` in `src/map/tileset.rs` ```bash # Extract building tiles to individual PNGs -cargo run -- map tiles "fixtures/Dispel/Map/cat1.btl" --output "out/cat1-btl" +cargo run -- map tiles "path/to/file.btl" --output "out/btl-tiles" # Generate building tileset atlas -cargo run -- map atlas "fixtures/Dispel/Map/cat1.btl" cat1.btl.png +cargo run -- map atlas "path/to/file.btl" btl-atlas.png ``` diff --git a/docs/files/Map/Map.gtl.md b/docs/files/Map/Map.gtl.md index 3812929e..82924429 100644 --- a/docs/files/Map/Map.gtl.md +++ b/docs/files/Map/Map.gtl.md @@ -1,112 +1,78 @@ -# cat1.gtl - Dispel Game Tileset File Format - -## File Information -- **Location**: `Map/*.gtl` and `Map/*.btl` files -- **Format**: Binary tileset -- **Tile Size**: 32×32 pixels -- **Color Format**: RGB565 (2 bytes per pixel) -- **Tile Data Size**: 2048 bytes per tile (32×32×2) - -## File Structure - -### No Header -- Direct sequence of tile data -- No metadata or header information -- Tiles are stored sequentially - -### Tile Structure (2048 bytes) -- `pixel_data`: 1024 × RGB565 pixels (32×32 grid) -- Each pixel: 2 bytes in RGB565 format - -### RGB565 Color Format -- **5 bits**: Red channel (0-31) -- **6 bits**: Green channel (0-63) -- **5 bits**: Blue channel (0-31) -- **Storage**: u16 format `0xRRRRRGGGGGGBBBBB` - -## File Types - -### .GTL Files (Ground Tile Layer) -- Terrain tiles -- Paths and roads -- Natural features (grass, water, rocks) -- Base landscape elements - -### .BTL Files (Building Tile Layer) -- Structures and buildings -- Roofs and architectural elements -- Man-made objects -- Decorative elements - -## Isometric Properties -- **Rendered Width**: 62 pixels -- **Rendered Height**: 32 pixels -- **Shape**: Diamond-shaped mask for isometric projection -- **Transparency**: RGB(0,0,0) treated as transparent - -## Color Conversion -The RGB565 format is converted to RGB888 during extraction: -- 5-bit red → 8-bit red (scaled 0-31 to 0-255) -- 6-bit green → 8-bit green (scaled 0-63 to 0-255) -- 5-bit blue → 8-bit blue (scaled 0-31 to 0-255) - -## File Size Calculation -``` -Total tiles = file_size / 2048 -``` +# DISPEL® Ground Tile Layer (`.gtl`) -## Related Files -- `*.map` - Map files that reference these tilesets -- `*.btl` - Building/roof tileset files +Raw tileset binary used for terrain, paths, and natural features. The format is identical to `.btl`; only the semantic +role differs. -## Tileset Files -- **Main maps**: `map1.gtl`, `map2.gtl`, `map3.gtl` -- **Catacombs**: `cat1.gtl`, `cat2.gtl`, `cat3.gtl`, `catp.gtl` -- **Dungeons**: `dun01.gtl` through `dun25.gtl`, `final.gtl` +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. -## Implementation -- **Rust Module**: `src/map/tileset.rs` -- **Extractor**: `extract` function -- **Renderer**: `plot_tileset_map` function -- **Tile Structure**: `Tile` struct with color data +## Quick Facts -## Example Usage +| Property | Value | +|---------------|---------------------------------| +| Location | `Map/*.gtl` | +| Header | None | +| Tile size | 32×32 px, RGB565 | +| Tile data | 2048 bytes per tile (32×32 × 2) | +| Rendered size | 62×32 px isometric diamond | +| Transparency | RGB(0,0,0) = transparent | -### Extract tiles to individual PNGs: -```bash -cargo run -- map tiles "fixtures/Dispel/Map/cat1.gtl" --output "out/cat1-gtl" -``` +## File Layout -### Generate tileset atlas: -```bash -cargo run -- map atlas "fixtures/Dispel/Map/cat1.gtl" cat1.gtl.png +No header. The file is a contiguous sequence of 32×32 pixel tiles in RGB565 (little-endian u16) format. Tile count = +`file_size / 2048`. + +``` +┌──────────────────────┐ +│ TILE #0 │ +│ pixels: u16 × 1024 │ RGB565, little-endian +├──────────────────────┤ +│ TILE #1 │ +│ pixels: u16 × 1024 │ +├──────────────────────┤ +│ ... │ +├──────────────────────┤ +│ TILE #N │ +│ pixels: u16 × 1024 │ +└──────────────────────┘ ``` -### Render map using tileset: -```bash -cargo run -- map render \ - --map "fixtures/Dispel/Map/cat1.map" \ - --btl "fixtures/Dispel/Map/cat1.btl" \ - --gtl "fixtures/Dispel/Map/cat1.gtl" \ - --output cat1.png +## RGB565 → RGB888 Conversion + ``` +red = bits 11–15 of pixel → scale 0–31 to 0–255 +green = bits 5–10 of pixel → scale 0–63 to 0–255 +blue = bits 0–4 of pixel → scale 0–31 to 0–255 +``` + +## Isometric Rendering -## Diamond Mask -The `create_mask` function generates a diamond-shaped mask for proper isometric tile rendering, creating the characteristic diamond shape of isometric tiles. +Tiles are projected as isometric diamonds (62×32 px). A diamond-shaped mask clips each tile so that corners blend +seamlessly. RGB (0,0,0) pixels are treated as transparent, allowing terrain tiles to layer with sprites and building +tiles. -## Transparency Handling -RGB(0,0,0) pixels are treated as transparent during rendering, allowing for proper layering of tiles and sprites. +## Relationship to `.map` Files -## Extractor +Each tile in a `.map` file's Tile & Access block encodes a GTL index in bits 10–24 of the packed u32. The renderer blits +from +`gtl_base + index × 2048` (one 32×32 RGB565 tile). -An extractor is available in `src/map/tileset.rs` to parse this file format. +## Related Files + +| File | Role | +|---------|-------------------------------------------------------| +| `*.btl` | Building tileset (same binary format, structure role) | +| `*.map` | Map files that reference GTL tile IDs | + +## Implementation -### How to Run +- Parser: `extract()` in `src/map/tileset.rs` +- Atlas: `plot_tileset_map()` in `src/map/tileset.rs` ```bash -# Extract tiles to individual PNGs -cargo run -- map tiles "fixtures/Dispel/Map/cat1.gtl" --output "out/cat1-gtl" +# Extract ground tiles to individual PNGs +cargo run -- map tiles "path/to/file.gtl" --output "out/gtl-tiles" -# Generate tileset atlas -cargo run -- map atlas "fixtures/Dispel/Map/cat1.gtl" cat1.gtl.png +# Generate ground tileset atlas +cargo run -- map atlas "path/to/file.gtl" gtl-atlas.png ``` diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index 43b49f01..bb9a2e5f 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -1,24 +1,23 @@ # DISPEL® Map File Format (`.map`) -All game maps — overworld (`map1.map`…), catacombs (`cat1.map`…, `catp.map`), -and dungeons (`dun01.map`…, `final.map`) — share this binary format. A `.map` -file contains everything needed to render a map: dimensions, embedded sprites -(thrones, statues, decor), building stacks, and three per-tile grids (events, -ground tiles, occlusion). +All game maps — overworld (`map1.map`…), catacombs (`cat1.map`…, `catp.map`), and dungeons (`dun01.map`…, `final.map`) — +share this binary format. A `.map` +file contains everything needed to render a map: dimensions, embedded sprites (thrones, statues, decor), building +stacks, and three per-tile grids (events, ground tiles, occlusion). > DISPEL® is a registered trademark. This project is not affiliated with, > endorsed by, or sponsored by the trademark owner. ## Quick Facts -| Property | Value | -|---|---| -| Location | `Map/*.map` | -| Endianness | Little-endian throughout | -| Grid unit | Tile, stored row-major (`y` outer, `x` inner) | -| Source tile | 32×32 px, RGB565 (2048 bytes) | -| Rendered tile | 62×32 px isometric diamond | -| Chunk | 25×25 tiles (dimensions are stored in chunks) | +| Property | Value | +|---------------|-----------------------------------------------| +| Location | `Map/*.map` | +| Endianness | Little-endian throughout | +| Grid unit | Tile, stored row-major (`y` outer, `x` inner) | +| Source tile | 32×32 px, RGB565 (2048 bytes) | +| Rendered tile | 62×32 px isometric diamond | +| Chunk | 25×25 tiles (dimensions are stored in chunks) | ## File Layout @@ -47,11 +46,11 @@ entries each, so a reader can seek to them from the end of the file. ### Header (12 bytes) -| Field | Type | Meaning | -|---|---|---| -| `width_in_chunks` | i32 | Map width in 25-tile chunks | -| `height_in_chunks` | i32 | Map height in 25-tile chunks | -| `border_count` | i32 | Border chunk count (always 2) | +| Field | Type | Meaning | +|--------------------|------|-------------------------------| +| `width_in_chunks` | i32 | Map width in 25-tile chunks | +| `height_in_chunks` | i32 | Map height in 25-tile chunks | +| `border_count` | i32 | Border chunk count (always 2) | ### First Block *(unused)* @@ -60,9 +59,8 @@ count: i32 records: (count − 1) × 8 bytes -- each record: value1: i32, value2: i32 ``` -Skipping `(count − 1) × 8` lands exactly on the next block's size field, -verified against the cat1/cat3/dun01/map1/catp fixtures. The pairs appear -unused by our tools; they are kept here for format completeness only. +Skipping `(count − 1) × 8` lands exactly on the next block's size field, verified against the cat1/cat3/dun01/map1/catp +fixtures. The pairs appear unused by our tools; they are kept here for format completeness only. ### Second Block *(u16 lookup table)* @@ -71,9 +69,8 @@ size: i32 data: size × 2 bytes -- table of u16 entries ``` -Not dead data: the Access-Ref block indexes into this table, and each -entry's low byte acts as a boolean flag consumed by occlusion/access checks. -All non-zero ids observed in shipped maps fall inside the table bounds. +Not dead data: the Access-Ref block indexes into this table, and each entry's low byte acts as a boolean flag consumed +by occlusion/access checks. All non-zero ids observed fall inside the table bounds. ### Sprite Block *(embedded sprites)* @@ -87,8 +84,8 @@ for each sprite: 6 → 1904 bytes, 9 → 2996 bytes ``` -Any other `image_stamp` is a parse error. Sprites here are thrones, statues, -decor — anything placed by pixel coordinate rather than tile grid. +Any other `image_stamp` is a parse error. Sprites here are thrones, statues, decor — anything placed by pixel coordinate +rather than tile grid. ### Sprite Info Block *(placements)* @@ -104,8 +101,7 @@ for each placement: frame_skip: (frame_count − 1) × 6 × 4 bytes ``` -`bottom_right_y` equals `y + sprite_height` and serves as the depth key for -interlaced rendering. +`bottom_right_y` equals `y + sprite_height` and serves as the depth key for interlaced rendering. ### Tiled Objects Block *(buildings)* @@ -126,9 +122,8 @@ for each bundle: -- see tiled_objects_block() in reader.rs ``` -Each bundle is one building: its `tile_ids` stack is drawn downward from the -anchor, one 62×32 diamond per entry. Negative ids occur and are skipped when -drawing. +Each bundle is one building: its `tile_ids` stack is drawn downward from the anchor, one 62×32 diamond per entry. +Negative ids occur and are skipped when drawing. ### Event Block @@ -143,23 +138,21 @@ unknown: i16 -- parameters/flags, semantics TBD ### Tile & Access Block -One packed u32 per tile, row-major (bit layout verified against shipped -map data): +One packed u32 per tile, row-major (bit layout verified against shipped map data): -| Bits | Meaning | -|---|---| -| 0 | Collision flag (tile blocked) | -| 1–9 | Object slot id (0–511); non-zero marks an interactive object | -| 10–24 | GTL ground-tile index — `(word >> 10) & 0x7FFF` | -| 25–31 | Unused (always 0 in shipped maps) | +| Bits | Meaning | +|-------|--------------------------------------------------------------| +| 0 | Collision flag (tile blocked) | +| 1–9 | Object slot id (0–511); non-zero marks an interactive object | +| 10–24 | GTL ground-tile index — bits 10–24 of the word | +| 25–31 | Unused (always 0) | The index points straight into the `.gtl` pixel data: the renderer blits from -`gtl_base + index × 2048` (one 32×32 RGB565 tile). Across observed maps the -maximum index matches the `.gtl` tile capacity minus one. +`gtl_base + index × 2048` (one 32×32 RGB565 tile). Across observed maps the maximum index matches the `.gtl` tile +capacity minus one. -Bits 0–9 together form one access field that can be rewritten without -touching the tile index. Shipped maps only ever use bit 0; bits 1–9 are -written at runtime. +Bits 0–9 together form one access field that can be rewritten without touching the tile index. Maps only ever +use bit 0; bits 1–9 are written at runtime. ### Access-Ref Block *(a.k.a. "roof" block)* @@ -170,9 +163,8 @@ ref_id: i16 -- bits 0–14: index into the Second Block tab flag: i16 -- rarely non-zero (a handful of border tiles) ``` -Despite the historical name, this grid drives **occlusion/access**, not -visuals — roof pixels come from the Tiled Objects block. Each referenced -table entry's low byte is a boolean flag used by occlusion checks. +Despite the historical name, this grid drives **occlusion/access**, not visuals — roof pixels come from the Tiled +Objects block. Each referenced table entry's low byte is a boolean flag used by occlusion checks. --- @@ -185,12 +177,11 @@ pixel_x = (x + y) × 32 pixel_y = (y − x) × 16 + (map_diagonal / 2) × 16 ``` -where `map_diagonal = tiled_width + tiled_height`. Moving `+1` in `x` steps -up-left on screen (−32, −16); moving `+1` in `y` steps down-right (+32, +16). +where `map_diagonal = tiled_width + tiled_height`. Moving `+1` in `x` steps up-left on screen (−32, −16); moving `+1` in +`y` steps down-right (+32, +16). Sprite and building placements inside the file are **map-local**: subtract -`map_non_occluded_start_x/y` (computed in `src/map/model.rs`) to convert to -world pixels. +`map_non_occluded_start_x/y` (computed in `src/map/model.rs`) to convert to world pixels. ## File Size Calculation @@ -201,12 +192,12 @@ total = header + variable blocks + fixed_tail ## Related Files -| File | Role | -|---|---| -| `*.gtl` | Ground tileset (raw 32×32 RGB565 tiles, no header) | -| `*.btl` | Building/roof tileset (same raw format) | -| `AllMap.ini` / `Map.ini` | Map metadata, per-map monster/NPC/extra ref files | -| `Mon*/Npc*/Ext*.ref` | External entities placed on the map | +| File | Role | +|--------------------------|----------------------------------------------------| +| `*.gtl` | Ground tileset (raw 32×32 RGB565 tiles, no header) | +| `*.btl` | Building/roof tileset (same raw format) | +| `AllMap.ini` / `Map.ini` | Map metadata, per-map monster/NPC/extra ref files | +| `Mon*/Npc*/Ext*.ref` | External entities placed on the map | ## Implementation & Usage diff --git a/docs/files/Map/MapModule.md b/docs/files/Map/MapModule.md index 73a3f28e..a038960a 100644 --- a/docs/files/Map/MapModule.md +++ b/docs/files/Map/MapModule.md @@ -1,12 +1,14 @@ # Map Module Specification -> This document references the mark solely for identification and compatibility purposes. No affiliation, endorsement, or sponsorship is implied. +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. --- ## Overview -The `src/map/` module handles parsing, rendering, and database operations for the Dispel game's `.MAP`, `.GTL`, and `.BTL` binary file formats. It provides: +The `src/map/` module handles parsing, rendering, and database operations for the Dispel game's `.MAP`, `.GTL`, and +`.BTL` binary file formats. It provides: - **Binary parsing** of `.MAP` files (geometry, sprites, tiles, events, collisions) - **Tileset extraction** from `.GTL` (ground) and `.BTL` (building) files @@ -16,16 +18,16 @@ The `src/map/` module handles parsing, rendering, and database operations for th ### Module Files -| File | Lines | Purpose | -|------|-------|---------| -| `mod.rs` | 546 | Public API, top-level `.MAP` parser, CLI commands, DB import | -| `types.rs` | 39 | Coordinate types, constants, data structs | -| `model.rs` | 72 | Map geometry computation from chunk dimensions | -| `reader.rs` | 246 | Binary block readers for each `.MAP` section | -| `render.rs` | 460 | Isometric rendering pipeline (ground → objects → roofs) | -| `tileset.rs` | 418 | `.GTL`/`.BTL` tileset extraction and atlas generation | -| `database.rs` | 512 | Render from SQLite + external entity (NPC/monster/extra) rendering | -| `sprite_loader.rs` | 137 | `.SPR` file frame loading and sprite plotting | +| File | Lines | Purpose | +|--------------------|-------|--------------------------------------------------------------------| +| `mod.rs` | 546 | Public API, top-level `.MAP` parser, CLI commands, DB import | +| `types.rs` | 39 | Coordinate types, constants, data structs | +| `model.rs` | 72 | Map geometry computation from chunk dimensions | +| `reader.rs` | 246 | Binary block readers for each `.MAP` section | +| `render.rs` | 460 | Isometric rendering pipeline (ground → objects → roofs) | +| `tileset.rs` | 418 | `.GTL`/`.BTL` tileset extraction and atlas generation | +| `database.rs` | 512 | Render from SQLite + external entity (NPC/monster/extra) rendering | +| `sprite_loader.rs` | 137 | `.SPR` file frame loading and sprite plotting | --- @@ -33,7 +35,8 @@ The `src/map/` module handles parsing, rendering, and database operations for th ### `.MAP` — Map File -Binary file containing the complete map definition: geometry, embedded sprites, tiled objects, event triggers, ground tiles, collision data, and optional roof tiles. +Binary file containing the complete map definition: geometry, embedded sprites, tiled objects, event triggers, ground +tiles, collision data, and optional roof tiles. #### Structure @@ -85,7 +88,7 @@ Binary file containing the complete map definition: geometry, embedded sprites, | tile_ids: i16 × c3 | ← BTL tile IDs stacked vertically | padding: 84 bytes | | extra_data: (c1+c2+c3) × 4 | -| Sentinel alignment (20 bytes scan for 0x01 marker) +| Sentinel alignment (20 bytes scan for the byte value 1) +-----------------------------------+ | EVENT BLOCK (read from end) | | For each tile (width × height): | @@ -130,12 +133,13 @@ occluded_map_in_pixels_height = map_height_in_pixels - 2 × map_non_occluded_sta - **Tile size**: 32×32 pixels - **Isometric projection**: diamond-shaped tiles - **Constants**: - - `TILE_HORIZONTAL_OFFSET_HALF = 32` - - `TILE_HEIGHT_HALF = 16` - - `TILE_WIDTH_HALF = 31` - - `TILE_PIXEL_NUMBER = 1024` + - `TILE_HORIZONTAL_OFFSET_HALF = 32` + - `TILE_HEIGHT_HALF = 16` + - `TILE_WIDTH_HALF = 31` + - `TILE_PIXEL_NUMBER = 1024` **Tile → Pixel conversion:** + ``` start_x = (x + y) × 32 start_y = (-x + y) × 16 + (diagonal / 2 × 16) @@ -144,10 +148,13 @@ start_y = (-x + y) × 16 + (diagonal / 2 × 16) #### Event Block Location The event block and tile blocks are located at the **end of the file**. The parser seeks backwards from EOF: + ``` seek_offset = -(tiled_map_width × tiled_map_height × 4 × 3) ``` -This accounts for 3 blocks of `width × height` records: events (4 bytes/tile), tiles (4 bytes/tile), and roof tiles (4 bytes/tile). + +This accounts for 3 blocks of `width × height` records: events (4 bytes/tile), tiles (4 bytes/tile), and roof tiles (4 +bytes/tile). --- @@ -179,16 +186,18 @@ Simple binary format with **no header**. Direct sequence of 32×32 pixel tiles i - **Tile count**: `file_size / 2048` - **Color format**: RGB565 (5-bit red, 6-bit green, 5-bit blue) - **Rendered size**: 62×32 pixels (isometric diamond) -- **Transparency**: RGB(0,0,0) = transparent +- **Transparency**: RGB (0,0,0) = transparent **RGB565 → RGB888 conversion:** + ``` -red = (pixel >> 11) & 0x1F → scale 0-31 to 0-255 -green = (pixel >> 5) & 0x3F → scale 0-63 to 0-255 -blue = pixel & 0x1F → scale 0-31 to 0-255 +red = bits 11–15 of pixel → scale 0-31 to 0-255 +green = bits 5–10 of pixel → scale 0-63 to 0-255 +blue = bits 0–4 of pixel → scale 0-31 to 0-255 ``` **File types:** + - `.GTL` — Ground Tile Layer (terrain, paths, natural features) - `.BTL` — Building Tile Layer (structures, roofs, man-made objects) @@ -305,39 +314,39 @@ struct LoadedSpriteFrame { ### Parsing -| Function | Input | Output | Description | -|----------|-------|--------|-------------| -| `read_map_data(reader)` | `BufReader` | `MapData` | Parse complete `.MAP` file | -| `read_map_model(reader)` | `BufReader` | `MapModel` | Parse header + compute geometry | -| `tileset::extract(path)` | `&Path` (.gtl/.btl) | `Vec` | Extract all tiles from tileset | -| `sprite_loader::load_sprite_frames(path)` | `&Path` (.spr) | `Option>` | Load first frame of each sequence | +| Function | Input | Output | Description | +|-------------------------------------------|---------------------|----------------------------------|-----------------------------------| +| `read_map_data(reader)` | `BufReader` | `MapData` | Parse complete `.MAP` file | +| `read_map_model(reader)` | `BufReader` | `MapModel` | Parse header + compute geometry | +| `tileset::extract(path)` | `&Path` (.gtl/.btl) | `Vec` | Extract all tiles from tileset | +| `sprite_loader::load_sprite_frames(path)` | `&Path` (.spr) | `Option>` | Load first frame of each sequence | ### Rendering -| Function | Description | -|----------|-------------| -| `extract(map, btl, gtl, output, save_sprites)` | Render `.MAP` + tilesets to PNG | -| `extract_sprites(map, output_dir)` | Extract embedded sprites to PNGs | +| Function | Description | +|--------------------------------------------------------------------------------------|-------------------------------------| +| `extract(map, btl, gtl, output, save_sprites)` | Render `.MAP` + tilesets to PNG | +| `extract_sprites(map, output_dir)` | Extract embedded sprites to PNGs | | `render_from_database(db, map_id, gtl_atlas, btl_atlas, columns, output, game_path)` | Render map from SQLite + atlas PNGs | ### Database -| Function | Description | -|----------|-------------| -| `import_to_database(db_path, map_path)` | Parse `.MAP` and save to SQLite | -| `save_to_db(conn, map_id, data)` | Write `MapData` to database tables | -| `save_map_tiles(params)` | Save tile/collision/event records | -| `save_map_objects(conn, map_id, tiled_infos)` | Save building object records | -| `save_map_sprites(conn, map_id, sprite_blocks)` | Save sprite placement records | -| `save_map_metadata(conn, map_id, model)` | Save map dimension metadata | +| Function | Description | +|-------------------------------------------------|------------------------------------| +| `import_to_database(db_path, map_path)` | Parse `.MAP` and save to SQLite | +| `save_to_db(conn, map_id, data)` | Write `MapData` to database tables | +| `save_map_tiles(params)` | Save tile/collision/event records | +| `save_map_objects(conn, map_id, tiled_infos)` | Save building object records | +| `save_map_sprites(conn, map_id, sprite_blocks)` | Save sprite placement records | +| `save_map_metadata(conn, map_id, model)` | Save map dimension metadata | ### Utilities -| Function | Description | -|----------|-------------| -| `convert_map_coords_to_image_coords(x, y, diagonal)` | Convert tile coords to pixel coords | -| `plot_atlas_tile(params)` | Blit tile from atlas with alpha blending | -| `plot_entity_sprite(dest, sprite, x, y, flip)` | Plot sprite frame with optional horizontal flip | +| Function | Description | +|------------------------------------------------------|-------------------------------------------------| +| `convert_map_coords_to_image_coords(x, y, diagonal)` | Convert tile coords to pixel coords | +| `plot_atlas_tile(params)` | Blit tile from atlas with alpha blending | +| `plot_entity_sprite(dest, sprite, x, y, flip)` | Plot sprite frame with optional horizontal flip | --- @@ -364,6 +373,7 @@ struct LoadedSpriteFrame { ### Depth Sorting Sprites and tiled objects are sorted by `ground_y` before rendering: + - **Sprites**: `ground_y = sprite_y + frame_height` - **Tiled objects**: `ground_y = tile_y + (stack_height × 32)` @@ -375,25 +385,25 @@ This ensures proper isometric depth ordering (painter's algorithm). ### Tables Created by Map Import -| Table | Columns | Description | -|-------|---------|-------------| -| `map_metadata` | `map_id`, `tiled_width`, `tiled_height`, `pixel_width`, `pixel_height`, `non_occluded_x`, `non_occluded_y`, `occluded_width`, `occluded_height` | Map dimensions and offsets | -| `map_tiles` | `map_id`, `x`, `y`, `gtl_tile_id`, `btl_tile_id`, `collision`, `event_id` | Per-tile ground/roof/collision/event data | -| `map_objects` | `map_id`, `object_index`, `x`, `y`, `btl_tile_id`, `stack_order` | Building tile stacks | -| `map_sprites` | `map_id`, `sprite_index`, `x`, `y`, `sprite_id` | Embedded sprite placements | +| Table | Columns | Description | +|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------| +| `map_metadata` | `map_id`, `tiled_width`, `tiled_height`, `pixel_width`, `pixel_height`, `non_occluded_x`, `non_occluded_y`, `occluded_width`, `occluded_height` | Map dimensions and offsets | +| `map_tiles` | `map_id`, `x`, `y`, `gtl_tile_id`, `btl_tile_id`, `collision`, `event_id` | Per-tile ground/roof/collision/event data | +| `map_objects` | `map_id`, `object_index`, `x`, `y`, `btl_tile_id`, `stack_order` | Building tile stacks | +| `map_sprites` | `map_id`, `sprite_index`, `x`, `y`, `sprite_id` | Embedded sprite placements | ### Tables Used by External Entity Rendering -| Table | Source | Description | -|-------|--------|-------------| -| `map_inis` | `references/map_ini.rs` | Map config with ref filenames | -| `maps` | `database.rs` | Map file → map_id mapping | -| `monster_refs` | `references/monster_ref.rs` | Monster placements | +| Table | Source | Description | +|----------------|-----------------------------|-----------------------------------------| +| `map_inis` | `references/map_ini.rs` | Map config with ref filenames | +| `maps` | `database.rs` | Map file → map_id mapping | +| `monster_refs` | `references/monster_ref.rs` | Monster placements | | `monster_inis` | `references/monster_ini.rs` | Monster visual config (sprite filename) | -| `npc_refs` | `references/npc_ref.rs` | NPC placements | -| `npc_inis` | `references/npc_ini.rs` | NPC visual config (sprite filename) | -| `extra_refs` | `references/extra_ref.rs` | Extra object placements | -| `extras` | `references/extra_ini.rs` | Extra object config (sprite filename) | +| `npc_refs` | `references/npc_ref.rs` | NPC placements | +| `npc_inis` | `references/npc_ini.rs` | NPC visual config (sprite filename) | +| `extra_refs` | `references/extra_ref.rs` | Extra object placements | +| `extras` | `references/extra_ini.rs` | Extra object config (sprite filename) | --- @@ -407,22 +417,65 @@ The following JSON schema describes the structure for exporting `.MAP` file data "title": "DispelMapData", "description": "Complete parsed representation of a Dispel game .MAP file", "type": "object", - "required": ["metadata", "gtl_tiles", "btl_tiles", "collisions", "events", "tiled_objects", "sprites"], + "required": [ + "metadata", + "gtl_tiles", + "btl_tiles", + "collisions", + "events", + "tiled_objects", + "sprites" + ], "properties": { "metadata": { "type": "object", - "required": ["chunk_width", "chunk_height", "tiled_width", "tiled_height"], + "required": [ + "chunk_width", + "chunk_height", + "tiled_width", + "tiled_height" + ], "properties": { - "chunk_width": { "type": "integer", "description": "Number of 25-tile chunks on X axis" }, - "chunk_height": { "type": "integer", "description": "Number of 25-tile chunks on Y axis" }, - "tiled_width": { "type": "integer", "description": "Total tile count on X axis (chunk_width * 25 - 1)" }, - "tiled_height": { "type": "integer", "description": "Total tile count on Y axis (chunk_height * 25 - 1)" }, - "map_width_in_pixels": { "type": "integer", "description": "Full rendered image width in pixels" }, - "map_height_in_pixels": { "type": "integer", "description": "Full rendered image height in pixels" }, - "non_occluded_start_x": { "type": "integer", "description": "Visible viewport X offset" }, - "non_occluded_start_y": { "type": "integer", "description": "Visible viewport Y offset" }, - "occluded_width": { "type": "integer", "description": "Cropped image width" }, - "occluded_height": { "type": "integer", "description": "Cropped image height" } + "chunk_width": { + "type": "integer", + "description": "Number of 25-tile chunks on X axis" + }, + "chunk_height": { + "type": "integer", + "description": "Number of 25-tile chunks on Y axis" + }, + "tiled_width": { + "type": "integer", + "description": "Total tile count on X axis (chunk_width * 25 - 1)" + }, + "tiled_height": { + "type": "integer", + "description": "Total tile count on Y axis (chunk_height * 25 - 1)" + }, + "map_width_in_pixels": { + "type": "integer", + "description": "Full rendered image width in pixels" + }, + "map_height_in_pixels": { + "type": "integer", + "description": "Full rendered image height in pixels" + }, + "non_occluded_start_x": { + "type": "integer", + "description": "Visible viewport X offset" + }, + "non_occluded_start_y": { + "type": "integer", + "description": "Visible viewport Y offset" + }, + "occluded_width": { + "type": "integer", + "description": "Cropped image width" + }, + "occluded_height": { + "type": "integer", + "description": "Cropped image height" + } } }, "gtl_tiles": { @@ -430,11 +483,24 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Ground tile assignments per coordinate", "items": { "type": "object", - "required": ["x", "y", "tile_id"], + "required": [ + "x", + "y", + "tile_id" + ], "properties": { - "x": { "type": "integer", "minimum": 0 }, - "y": { "type": "integer", "minimum": 0 }, - "tile_id": { "type": "integer", "description": "Index into the .GTL tileset" } + "x": { + "type": "integer", + "minimum": 0 + }, + "y": { + "type": "integer", + "minimum": 0 + }, + "tile_id": { + "type": "integer", + "description": "Index into the .GTL tileset" + } } } }, @@ -443,11 +509,24 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Roof/building tile assignments per coordinate", "items": { "type": "object", - "required": ["x", "y", "tile_id"], + "required": [ + "x", + "y", + "tile_id" + ], "properties": { - "x": { "type": "integer", "minimum": 0 }, - "y": { "type": "integer", "minimum": 0 }, - "tile_id": { "type": "integer", "description": "Index into the .BTL tileset" } + "x": { + "type": "integer", + "minimum": 0 + }, + "y": { + "type": "integer", + "minimum": 0 + }, + "tile_id": { + "type": "integer", + "description": "Index into the .BTL tileset" + } } } }, @@ -456,11 +535,24 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Collision flags per coordinate", "items": { "type": "object", - "required": ["x", "y", "blocked"], + "required": [ + "x", + "y", + "blocked" + ], "properties": { - "x": { "type": "integer", "minimum": 0 }, - "y": { "type": "integer", "minimum": 0 }, - "blocked": { "type": "boolean", "description": "Whether this tile blocks movement" } + "x": { + "type": "integer", + "minimum": 0 + }, + "y": { + "type": "integer", + "minimum": 0 + }, + "blocked": { + "type": "boolean", + "description": "Whether this tile blocks movement" + } } } }, @@ -469,11 +561,24 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Event triggers per coordinate", "items": { "type": "object", - "required": ["x", "y", "event_id"], + "required": [ + "x", + "y", + "event_id" + ], "properties": { - "x": { "type": "integer", "minimum": 0 }, - "y": { "type": "integer", "minimum": 0 }, - "event_id": { "type": "integer", "description": "Event trigger ID (0 = no event)" } + "x": { + "type": "integer", + "minimum": 0 + }, + "y": { + "type": "integer", + "minimum": 0 + }, + "event_id": { + "type": "integer", + "description": "Event trigger ID (0 = no event)" + } } } }, @@ -482,14 +587,30 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Buildings and objects made of stacked BTL tiles", "items": { "type": "object", - "required": ["index", "x", "y", "tile_ids"], + "required": [ + "index", + "x", + "y", + "tile_ids" + ], "properties": { - "index": { "type": "integer", "description": "Object index (0-based)" }, - "x": { "type": "integer", "description": "Tile X coordinate" }, - "y": { "type": "integer", "description": "Tile Y coordinate" }, + "index": { + "type": "integer", + "description": "Object index (0-based)" + }, + "x": { + "type": "integer", + "description": "Tile X coordinate" + }, + "y": { + "type": "integer", + "description": "Tile Y coordinate" + }, "tile_ids": { "type": "array", - "items": { "type": "integer" }, + "items": { + "type": "integer" + }, "description": "BTL tile IDs stacked bottom-to-top" } } @@ -500,12 +621,29 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Embedded sprite placements", "items": { "type": "object", - "required": ["index", "sprite_id", "x", "y"], + "required": [ + "index", + "sprite_id", + "x", + "y" + ], "properties": { - "index": { "type": "integer", "description": "Placement index (0-based)" }, - "sprite_id": { "type": "integer", "description": "Index into internal_sprites array" }, - "x": { "type": "integer", "description": "Pixel X position on map" }, - "y": { "type": "integer", "description": "Pixel Y position on map" } + "index": { + "type": "integer", + "description": "Placement index (0-based)" + }, + "sprite_id": { + "type": "integer", + "description": "Index into internal_sprites array" + }, + "x": { + "type": "integer", + "description": "Pixel X position on map" + }, + "y": { + "type": "integer", + "description": "Pixel Y position on map" + } } } }, @@ -514,21 +652,56 @@ The following JSON schema describes the structure for exporting `.MAP` file data "description": "Embedded sprite sequence definitions", "items": { "type": "object", - "required": ["index", "image_stamp", "frame_count", "frames"], + "required": [ + "index", + "image_stamp", + "frame_count", + "frames" + ], "properties": { - "index": { "type": "integer", "description": "Sprite index (0-based)" }, - "image_stamp": { "type": "integer", "enum": [6, 9], "description": "Data layout variant" }, - "frame_count": { "type": "integer", "description": "Number of animation frames" }, + "index": { + "type": "integer", + "description": "Sprite index (0-based)" + }, + "image_stamp": { + "type": "integer", + "enum": [ + 6, + 9 + ], + "description": "Data layout variant" + }, + "frame_count": { + "type": "integer", + "description": "Number of animation frames" + }, "frames": { "type": "array", "items": { "type": "object", - "required": ["width", "height", "origin_x", "origin_y"], + "required": [ + "width", + "height", + "origin_x", + "origin_y" + ], "properties": { - "width": { "type": "integer", "minimum": 0 }, - "height": { "type": "integer", "minimum": 0 }, - "origin_x": { "type": "integer", "description": "Anchor offset X" }, - "origin_y": { "type": "integer", "description": "Anchor offset Y" } + "width": { + "type": "integer", + "minimum": 0 + }, + "height": { + "type": "integer", + "minimum": 0 + }, + "origin_x": { + "type": "integer", + "description": "Anchor offset X" + }, + "origin_y": { + "type": "integer", + "description": "Anchor offset Y" + } } } } @@ -549,33 +722,101 @@ The following JSON schema describes the structure for exporting `.MAP` file data "title": "DispelTileset", "description": "Extracted tileset from .GTL or .BTL file", "type": "object", - "required": ["tile_count", "tile_width", "tile_height", "color_format", "tiles"], + "required": [ + "tile_count", + "tile_width", + "tile_height", + "color_format", + "tiles" + ], "properties": { - "tile_count": { "type": "integer", "description": "Total number of tiles" }, - "tile_width": { "type": "integer", "const": 32, "description": "Tile pixel width" }, - "tile_height": { "type": "integer", "const": 32, "description": "Tile pixel height" }, - "rendered_width": { "type": "integer", "const": 62, "description": "Isometric diamond width" }, - "rendered_height": { "type": "integer", "const": 32, "description": "Isometric diamond height" }, - "color_format": { "type": "string", "const": "RGB565", "description": "Source color encoding" }, - "file_type": { "type": "string", "enum": ["gtl", "btl"], "description": "Ground or building tile layer" }, + "tile_count": { + "type": "integer", + "description": "Total number of tiles" + }, + "tile_width": { + "type": "integer", + "const": 32, + "description": "Tile pixel width" + }, + "tile_height": { + "type": "integer", + "const": 32, + "description": "Tile pixel height" + }, + "rendered_width": { + "type": "integer", + "const": 62, + "description": "Isometric diamond width" + }, + "rendered_height": { + "type": "integer", + "const": 32, + "description": "Isometric diamond height" + }, + "color_format": { + "type": "string", + "const": "RGB565", + "description": "Source color encoding" + }, + "file_type": { + "type": "string", + "enum": [ + "gtl", + "btl" + ], + "description": "Ground or building tile layer" + }, "tiles": { "type": "array", "items": { "type": "object", - "required": ["index", "pixels"], + "required": [ + "index", + "pixels" + ], "properties": { - "index": { "type": "integer", "description": "Tile index (0-based, used as tile_id in map)" }, + "index": { + "type": "integer", + "description": "Tile index (0-based, used as tile_id in map)" + }, "pixels": { "type": "array", "items": { "type": "object", - "required": ["x", "y", "r", "g", "b"], + "required": [ + "x", + "y", + "r", + "g", + "b" + ], "properties": { - "x": { "type": "integer", "minimum": 0, "maximum": 31 }, - "y": { "type": "integer", "minimum": 0, "maximum": 31 }, - "r": { "type": "integer", "minimum": 0, "maximum": 255 }, - "g": { "type": "integer", "minimum": 0, "maximum": 255 }, - "b": { "type": "integer", "minimum": 0, "maximum": 255 } + "x": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "y": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "r": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } } }, "minItems": 1024, @@ -592,25 +833,25 @@ The following JSON schema describes the structure for exporting `.MAP` file data ## Quick Reference: Byte Offsets in `.MAP` File -| Offset | Size | Field | Notes | -|--------|------|-------|-------| -| 0x00 | 4 | `chunk_width` | i32 LE | -| 0x04 | 4 | `chunk_height` | i32 LE | -| 0x08 | 4 | `first_block_multiplier` | i32 LE | -| 0x0C | 4 | `first_block_size` | i32 LE | -| 0x10 | var | `first_block_data` | Skip `multiplier * size * 4` bytes | -| var | 4 | `second_block_size` | i32 LE | -| var | var | `second_block_data` | Skip `size * 2` bytes | -| var | 4 | `sprite_count` | i32 LE | -| var | var | `sprite entries` | Each: stamp(4) + meta(264) + sequence_info + padding | -| var | 4 | `sprite_placement_count` | i32 LE | -| var | var | `sprite placements` | Each: 7×i32 + variable frame skip | -| var | 4 | `tiled_object_count` | i32 LE | -| var | 4 | `tiled_object_number1` | i32 LE | -| var | var | `tiled objects` | Each: 264 + 8×i32 + 3×i32 + c3×i16 + 84 + (c1+c2+c3)×4 | -| EOF-(w×h×12) | w×h×4 | `event blocks` | Each: event_id(i16) + unknown(i16) | -| EOF-(w×h×8) | w×h×4 | `tile & access` | Each: packed_value(i32) | -| EOF-(w×h×4) | w×h×4 | `roof tiles` | Each: btl_id(i16) + flag(i16) | +| Offset | Size | Field | Notes | +|--------------|-------|--------------------------|--------------------------------------------------------| +| 0 | 4 | `chunk_width` | i32 LE | +| 4 | 4 | `chunk_height` | i32 LE | +| 8 | 4 | `first_block_multiplier` | i32 LE | +| 12 | 4 | `first_block_size` | i32 LE | +| 16 | var | `first_block_data` | Skip `multiplier * size * 4` bytes | +| var | 4 | `second_block_size` | i32 LE | +| var | var | `second_block_data` | Skip `size * 2` bytes | +| var | 4 | `sprite_count` | i32 LE | +| var | var | `sprite entries` | Each: stamp(4) + meta(264) + sequence_info + padding | +| var | 4 | `sprite_placement_count` | i32 LE | +| var | var | `sprite placements` | Each: 7×i32 + variable frame skip | +| var | 4 | `tiled_object_count` | i32 LE | +| var | 4 | `tiled_object_number1` | i32 LE | +| var | var | `tiled objects` | Each: 264 + 8×i32 + 3×i32 + c3×i16 + 84 + (c1+c2+c3)×4 | +| EOF-(w×h×12) | w×h×4 | `event blocks` | Each: event_id(i16) + unknown(i16) | +| EOF-(w×h×8) | w×h×4 | `tile & access` | Each: packed_value(i32) | +| EOF-(w×h×4) | w×h×4 | `roof tiles` | Each: btl_id(i16) + flag(i16) | Where `w = tiled_map_width`, `h = tiled_map_height`. @@ -620,11 +861,11 @@ Where `w = tiled_map_width`, `h = tiled_map_height`. When `game_path` is not provided or sprite files are missing, entities are rendered as colored diamond markers: -| Entity Type | Color (RGBA) | Sprite Directory | -|-------------|-------------|------------------| -| Monsters | `rgba(255, 60, 60, 255)` — red | `MonsterInGame/` | -| NPCs | `rgba(60, 255, 60, 255)` — green | `NpcInGame/` | -| Extras | `rgba(80, 120, 255, 255)` — blue | `ExtraInGame/` | +| Entity Type | Color (RGBA) | Sprite Directory | +|-------------|----------------------------------|------------------| +| Monsters | `rgba(255, 60, 60, 255)` — red | `MonsterInGame/` | +| NPCs | `rgba(60, 255, 60, 255)` — green | `NpcInGame/` | +| Extras | `rgba(80, 120, 255, 255)` — blue | `ExtraInGame/` | --- @@ -632,7 +873,8 @@ When `game_path` is not provided or sprite files are missing, entities are rende 1. **Two unknown blocks** at the start of `.MAP` files are skipped — their purpose is not yet understood 2. **Image stamps** (6 or 9) determine the padding size after sprite sequence data (1904 or 2996 bytes respectively) -3. **Tiled object sentinel** — the end of the tiled objects block is detected by scanning 20 bytes backwards for a `0x01` marker +3. **Tiled object sentinel** — the end of the tiled objects block is detected by scanning 20 bytes backwards for the + byte value 1 4. **Roof block is optional** — only parsed if remaining file size is sufficient 5. **Collision flag** is extracted from bit 0 of the packed tile value; tile ID is bits 10–31 6. **All integers are little-endian** diff --git a/docs/files/MonsterInGame/MondunMonmap.ref.md b/docs/files/MonsterInGame/MondunMonmap.ref.md index 4b29301a..d0de4a27 100644 --- a/docs/files/MonsterInGame/MondunMonmap.ref.md +++ b/docs/files/MonsterInGame/MondunMonmap.ref.md @@ -1,5 +1,8 @@ # Mondun/Monmap Files - Monster Placement References +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information - **Location**: `MonsterInGame/` directory @@ -10,9 +13,9 @@ Binary files that define monster placements, coordinates, event triggers, and lo ## File Types -| File Pattern | Map Type | Description | -|--------------|----------|-------------| -| `Mondun*.ref` | Dungeon | Monster placements for dungeon maps (e.g., Mondun01.ref, Mondun02.ref) | +| File Pattern | Map Type | Description | +|---------------|-----------|--------------------------------------------------------------------------------| +| `Mondun*.ref` | Dungeon | Monster placements for dungeon maps (e.g., Mondun01.ref, Mondun02.ref) | | `Monmap*.ref` | Overworld | Monster placements for regular/overworld maps (e.g., Monmap1.ref, Monmap2.ref) | ## Binary Format @@ -25,28 +28,30 @@ Binary files that define monster placements, coordinates, event triggers, and lo | Offset | Field | Meaning | |---:|---|---| -| 0x00 | placement_id | Map-local monster-placement identifier. | -| 0x04 | monster_db_id | One-based monster ID, used by `Monster.db` and `Monster.ini`. | -| 0x08 | map_x | Spawn tile X coordinate. | -| 0x0c | map_y | Spawn tile Y coordinate. | -| 0x10 | initial_patrol_countdown | Initial patrol/scan countdown. | -| 0x14 | skip_ai_action | When set, skips an AI action branch. | -| 0x18 | initial_active_flag | Initial active/awake runtime flag. Original map files observed so far use zero. | -| 0x1c | ai_type_override | `-1` uses the `Monster.db` AI type; 0 or 1 overrides it. | -| 0x20 | event_id_on_kill | Event ID triggered after this monster dies. | -| 0x24 | loot_item_1 | First packed `InventoryItem` loot slot. | -| 0x28 | loot_item_2 | Second packed `InventoryItem` loot slot. | -| 0x2c | loot_item_3 | Third packed `InventoryItem` loot slot. | -| 0x30 | drop_all_loot | `1` drops all populated loot slots; other observed values select a slot. | -| 0x34 | force_ai_update | `1` runs the AI update path even when the normal active flag is clear. | +| 0 | placement_id | Map-local monster-placement identifier. | +| 4 | monster_db_id | One-based monster ID, used by `Monster.db` and `Monster.ini`. | +| 8 | map_x | Spawn tile X coordinate. | +| 12 | map_y | Spawn tile Y coordinate. | +| 16 | initial_patrol_countdown | Initial patrol/scan countdown. | +| 20 | skip_ai_action | When set, skips an AI action branch. | +| 24 | initial_active_flag | Initial active/awake runtime flag. Original map files observed so far use zero. | +| 28 | ai_type_override | `-1` uses the `Monster.db` AI type; 0 or 1 overrides it. | +| 32 | event_id_on_kill | Event ID triggered after this monster dies. | +| 36 | loot_item_1 | First packed `InventoryItem` loot slot. | +| 40 | loot_item_2 | Second packed `InventoryItem` loot slot. | +| 44 | loot_item_3 | Third packed `InventoryItem` loot slot. | +| 48 | drop_all_loot | `1` drops all populated loot slots; other observed values select a slot. | +| 52 | force_ai_update | `1` runs the AI update path even when the normal active flag is clear. | ``` ## Example Files **Dungeon Maps (Mondun*.ref):** + - Mondun01.ref through Mondun25.ref **Overworld Maps (Monmap*.ref):** + - Monmap1.ref, Monmap2.ref, Monmap3.ref ## Map.ini Integration @@ -69,30 +74,19 @@ These files are referenced in `Ref/Map.ini` to associate monster placements with **event_id_on_kill**: Links to the event triggered when the monster dies. -**loot_item_1..3**: Packed 32-bit `InventoryItem` values. The low bytes contain the item ID and type; the upper bits are preserved when writing. - -## Usage in Game - -1. Game loads map from `Map.ini` -2. References the associated monster file (Mondun*.ref or Monmap*.ref) -3. Spawns monsters at specified coordinates -4. Configures loot drops based on item IDs and types -5. Links monster behavior to `monster_db_id` definitions -6. Triggers `event_id_on_kill` when the monster dies - -## Monster Type IDs -- Links to `Monster.db` entries -- Identifies specific monster types -- Determines monster appearance and stats +**loot_item_1..3**: Packed 32-bit `InventoryItem` values. The low bytes contain the item ID and type; the upper bits are +preserved when writing. ## Loot System ### Loot Slots + - 3 loot slots per monster - Each slot has item ID and type - Items dropped when monster is defeated ### Item Types + - `Weapon` (1): Weapons and combat items - `Healing` (2): Health restoration items - `Edit` (3): Modifiable equipment @@ -100,15 +94,8 @@ These files are referenced in `Ref/Map.ini` to associate monster placements with - `Misc` (5): Various utility items - `Other` (255): Undefined/catch-all -## File Purpose -Defines monster placements on specific maps with exact coordinates, event triggers, and loot drop configurations. Used for: -- Populating dungeons with enemies -- Setting up ambush points -- Creating balanced combat encounters -- Distributing loot rewards -- World building and difficulty scaling - ## Related Files + - `Monster.db` - Monster definitions and statistics - `Monster.ini` - Monster visual/sprite data - `Event.ini` - Event definitions referenced by `event_id_on_kill` @@ -116,6 +103,7 @@ Defines monster placements on specific maps with exact coordinates, event trigge - `AllMap.ini` - Map metadata and associations ## Implementation + - **Rust Module**: `src/references/monster_ref.rs` - **Editor**: `src/references/monster_ref_editor.rs` (EditableRecord impl) - **Extractor**: `MonsterRef` struct implementing `Extractor` trait @@ -125,40 +113,27 @@ Defines monster placements on specific maps with exact coordinates, event trigge ## Example Usage ### Extract monster placements (new CLI): + ```bash cargo run -- extract -i fixtures/Dispel/MonsterInGame/Mondun01.ref ``` ### Extract monster placements (legacy): + ```bash cargo run -- extract -i "fixtures/Dispel/MonsterInGame/Mondun01.ref" ``` ### Import to database: + ```bash cargo run -- database import "fixtures/Dispel/" ``` ## Coordinate System -- Isometric tile-based coordinates -- Each tile is 32×32 pixels -- Origin typically at top-left of map -- Y-axis increases downward - -## Legal Notice -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, monster data, or proprietary assets. All references to monster types and placements are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -### Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any monster data or game content -- Focuses on **binary structure and organization**, not creative content -- Uses **generic descriptions** of file purposes -- Maintains **nominal fair use** for trademark references +- Tile-based coordinates +- Y-axis increases downward ## Technical Notes @@ -166,7 +141,6 @@ This documentation: - Distinction is organizational (dungeon vs overworld) - Padding fields have constrained value ranges (see Binary Format above) - Files are processed by `MonsterRef` struct in the codebase -- **No copyrighted game content** is reproduced or distributed ## Extractor @@ -188,6 +162,7 @@ cargo run -- validate -i monsteref.json --type monster_ref ### GUI Editor The MonsterRef editor provides a 3-panel interface: + 1. **File list** — discovered Mondun*/Monmap*.ref files 2. **Record list** — monster placements in the selected file 3. **Record editor** — editable fields with monster name dropdown (loaded from Monster.ini) diff --git a/docs/files/MonsterInGame/Monster.db.md b/docs/files/MonsterInGame/Monster.db.md index ae4f5aa8..8e120007 100644 --- a/docs/files/MonsterInGame/Monster.db.md +++ b/docs/files/MonsterInGame/Monster.db.md @@ -1,17 +1,20 @@ # Monster.db Documentation +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information ### Overview -Binary database file that defines all monster types with complete combat statistics, behavior patterns, and reward systems for the game's monster spawning and combat AI. +Binary database file that defines all monster types with complete combat statistics, behavior patterns, and reward +systems for the game's monster spawning and combat AI. ### File Structure **Location**: `MonsterInGame/Monster.db` **Encoding**: Binary (Little-Endian) -**Text Encoding**: EUC-KR -**Record Size**: 160 bytes (40 × 4-byte fields) +**Text Encoding**: EUC-KR **Record Size**: 160 bytes (24-byte name + 34 × i32 fields) **Header**: None (count derived from file size) **Total Records**: Variable (file_size / 160) @@ -60,52 +63,54 @@ Binary database file that defines all monster types with complete combat statist ### Field Definitions -| Field | Size | Type | Description | -|-------|------|------|-------------| -| id | N/A | i32 | Record index (assigned during parsing) | -| name | 24 | string | Monster name (EUC-KR encoded) | -| health_points_max | 4 | i32 | Maximum HP ceiling | -| health_points_min | 4 | i32 | Minimum HP floor | -| mana_points_max | 4 | i32 | Maximum MP limit | -| mana_points_min | 4 | i32 | Minimum MP limit | -| walk_speed | 4 | i32 | Baseline tiles moved per tick | -| to_hit_max | 4 | i32 | Upper bound of accuracy | -| to_hit_min | 4 | i32 | Lower bound of accuracy | -| to_dodge_max | 4 | i32 | Upper bound for evasion rate | -| to_dodge_min | 4 | i32 | Lower bound for evasion rate | -| offense_max | 4 | i32 | Maximum physical damage | -| offense_min | 4 | i32 | Minimum physical damage | -| defense_max | 4 | i32 | Upper bound armor class | -| defense_min | 4 | i32 | Lower bound armor class | -| magic_attack_max | 4 | i32 | Maximum magical intensity | -| magic_attack_min | 4 | i32 | Minimum magical intensity | -| is_undead | 4 | i32 | Undead flag (PropertyFlag) | -| has_blood | 4 | i32 | Blood flag (PropertyFlag) | -| ai_type | 4 | i32 | Combat behavior (MonsterAiType) | -| exp_gain_max | 4 | i32 | High roll for experience points | -| exp_gain_min | 4 | i32 | Low roll for experience points | -| gold_drop_max | 4 | i32 | Maximum gold drop | -| gold_drop_min | 4 | i32 | Minimum gold drop | -| detection_sight_size | 4 | i32 | Aggro radius in tiles | -| distance_range_size | 4 | i32 | Maximum engage distance | -| known_spell_slot1 | 4 | i32 | Primary magic spell index | -| known_spell_slot2 | 4 | i32 | Secondary magic spell index | -| known_spell_slot3 | 4 | i32 | Tertiary magic spell index | -| is_oversize | 4 | i32 | Oversize collision flag | -| magic_level | 4 | i32 | Spellcasting potency | -| special_attack | 4 | i32 | Unique monster skill ID | -| special_attack_chance | 4 | i32 | Percentage likelihood | -| special_attack_duration | 4 | i32 | Effect duration in ticks | -| boldness | 4 | i32 | Retreat threshold metric | -| attack_speed | 4 | i32 | Delay ticks between attacks | +| Field | Size | Type | Description | +|-------------------------|------|--------|----------------------------------------| +| id | N/A | i32 | Record index (assigned during parsing) | +| name | 24 | string | Monster name (EUC-KR encoded) | +| health_points_max | 4 | i32 | Maximum HP ceiling | +| health_points_min | 4 | i32 | Minimum HP floor | +| mana_points_max | 4 | i32 | Maximum MP limit | +| mana_points_min | 4 | i32 | Minimum MP limit | +| walk_speed | 4 | i32 | Baseline tiles moved per tick | +| to_hit_max | 4 | i32 | Upper bound of accuracy | +| to_hit_min | 4 | i32 | Lower bound of accuracy | +| to_dodge_max | 4 | i32 | Upper bound for evasion rate | +| to_dodge_min | 4 | i32 | Lower bound for evasion rate | +| offense_max | 4 | i32 | Maximum physical damage | +| offense_min | 4 | i32 | Minimum physical damage | +| defense_max | 4 | i32 | Upper bound armor class | +| defense_min | 4 | i32 | Lower bound armor class | +| magic_attack_max | 4 | i32 | Maximum magical intensity | +| magic_attack_min | 4 | i32 | Minimum magical intensity | +| is_undead | 4 | i32 | Undead flag (PropertyFlag) | +| has_blood | 4 | i32 | Blood flag (PropertyFlag) | +| ai_type | 4 | i32 | Combat behavior (MonsterAiType) | +| exp_gain_max | 4 | i32 | High roll for experience points | +| exp_gain_min | 4 | i32 | Low roll for experience points | +| gold_drop_max | 4 | i32 | Maximum gold drop | +| gold_drop_min | 4 | i32 | Minimum gold drop | +| detection_sight_size | 4 | i32 | Aggro radius in tiles | +| distance_range_size | 4 | i32 | Maximum engage distance | +| known_spell_slot1 | 4 | i32 | Primary magic spell index | +| known_spell_slot2 | 4 | i32 | Secondary magic spell index | +| known_spell_slot3 | 4 | i32 | Tertiary magic spell index | +| is_oversize | 4 | i32 | Oversize collision flag | +| magic_level | 4 | i32 | Spellcasting potency | +| special_attack | 4 | i32 | Unique monster skill ID | +| special_attack_chance | 4 | i32 | Percentage likelihood | +| special_attack_duration | 4 | i32 | Effect duration in ticks | +| boldness | 4 | i32 | Retreat threshold metric | +| attack_speed | 4 | i32 | Delay ticks between attacks | ### Enumeration Types **PropertyFlag (is_undead, has_blood):** + - **0**: Absent - Property is false/absent - **1**: Present - Property is true/present **MonsterAiType (ai_type):** + - **0**: Aggressor — Actively seeks and engages the player - **1**: HitAndFlee — Attacks then retreats (hit-and-run) - **2**: FleeWhenApproached — Runs away when player approaches @@ -203,117 +208,16 @@ Offset | Size | Field | Description ### Special Values -- **to_dodge_max/min**: Usually both = 10 -- **magic_level**: Usually = 1 -- **boldness**: Usually = 10 +- **to_dodge_max/min**: Often both = 10 +- **magic_level**: Often = 1 +- **boldness**: Often = 10 - **is_undead = 1**: Holy weakness, no bleed - **has_blood = 0**: No blood effects (golems) - **is_oversize = 1**: Large monsters (dragons, etc.) -### Usage in Game - -1. **Combat System**: Monster stats for battles -2. **AI System**: Behavior patterns and tactics -3. **Reward System**: EXP and gold drops -4. **Spawning System**: Monster placement and variety -5. **Progression System**: Balanced difficulty curve - -### File Characteristics - -- **Record Size**: 160 bytes (fixed) -- **No Header**: Record count derived from file size -- **Encoding**: EUC-KR for monster names -- **Structure**: Comprehensive statistical system -- **Complexity**: 40 fields per monster - -### Technical Details - -**Text Encoding:** -- EUC-KR for monster names -- Null-terminated strings with padding -- Fixed field size (24 bytes) - -**Binary Processing:** -- Little-endian byte order -- No header validation -- Fixed record size parsing -- Enum conversion for flags - -**Database Integration:** -- Processed by `Monster` struct -- Uses type-safe enums for flags -- Stored with all statistical fields -- Linked to combat and spawning systems - -### Combat System Analysis - -**Vital Statistics:** -- HP/MP ranges for variability -- Walk speed affects movement -- Accuracy and evasion mechanics - -**Damage Systems:** -- Physical offense/defense -- Magical attack ranges -- Special attack mechanics - -**Reward Systems:** -- EXP and gold drop ranges -- Balanced risk/reward -- Progression scaling - -**AI Systems:** -- Behavior type patterns -- Detection and range mechanics -- Special abilities - -### Notes - -- File uses binary format with EUC-KR text encoding -- No header - record count calculated from file size -- Complex statistical system for balanced combat -- Integrated with spawning and AI systems -- **No copyrighted game content** is reproduced or distributed - -### Comparison with Other Databases - -**Monster.db vs Mondun/Monmap.ref:** -- **Monster.db**: Monster statistics and AI -- **Mondun/Monmap.ref**: Monster placements -- **Monster.db**: Combat properties -- **Mondun/Monmap.ref**: Spatial positioning - -**Monster.db vs Npc.ini:** -- **Monster.db**: Combat monsters -- **Npc.ini**: Non-combat NPCs -- **Monster.db**: Aggressive AI -- **Npc.ini**: Passive interactions - -### File Purpose Summary - -Monster.db serves as a comprehensive database for: -- Monster combat statistics -- AI behavior patterns -- Reward systems (EXP/gold) -- Special abilities and spells -- Balanced difficulty progression - -The file provides a sophisticated system for managing all monster types in the game, supporting diverse combat encounters with varied statistics, behaviors, and reward structures. - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, monster data, or proprietary assets. All references to creature systems are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -### Legal Compliance +### File Purpose -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any monster data or game content -- Focuses on **technical organization and combat systems** -- Explains **statistical mechanics and AI behaviors** -- Maintains **nominal fair use** for trademark references +Defines all monster types with combat statistics, AI behavior patterns, and reward systems. ## Extractor diff --git a/docs/files/Npc.ini.md b/docs/files/Npc.ini.md index 53b5f72f..920337e1 100644 --- a/docs/files/Npc.ini.md +++ b/docs/files/Npc.ini.md @@ -1,5 +1,8 @@ # Npc.ini Documentation +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information ### Overview @@ -14,21 +17,21 @@ Text file that defines visual appearances and descriptions for NPC (Non-Player C ### Format Specification -``` +```ini ; Comment line id,sprite_filename,description -1,guard1.spr,City Guard +1,guard.spr,City Guard 2,merchant.spr,Shopkeeper ... ``` ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| id | i32 | Unique NPC visual type identifier (0-99+) | -| sprite_filename | string | SPR filename or "null" for no sprite | -| description | string | NPC role/appearance description (EUC-KR encoded) | +| Field | Type | Description | +|-----------------|--------|--------------------------------------------------| +| id | i32 | Unique NPC visual type identifier | +| sprite_filename | string | SPR filename or "null" for no sprite | +| description | string | NPC role/appearance description (EUC-KR encoded) | ### Special Values @@ -40,34 +43,33 @@ id,sprite_filename,description ```ini ; Party members -1,Party1.spr,NPC description -2,Party2.spr,NPC description -3,Party3.spr,NPC description +1,Party1.spr,Party Member 1 +2,Party2.spr,Party Member 2 ; Guards -9,guard1.spr,NPC description -10,guard2.spr,NPC description -11,guard3.spr,NPC description +9,guard1.spr,City Guard +10,guard2.spr,Town Guard ; Kings -16,King1.spr,NPC description -17,King2.spr,NPC description -18,King3.spr,NPC description +16,King1.spr,Royal Guard +17,King2.spr,Royal Guard ``` ### Technical Details **Encoding**: EUC-KR (Extended Unix Code Korea) + - Supports Korean characters used in descriptions - Requires proper encoding handling for reading/writing **File Processing**: + - Comments (lines starting with ";") are ignored - Empty lines are skipped - CSV format with comma delimiter - "null" literal used for missing sprite filenames -### Usage in Game +### Usage 1. Game loads NPC definitions from Npc.ini 2. Links NPC visual types to behavior scripts @@ -77,37 +79,21 @@ id,sprite_filename,description ### Notes -- Some entries have "null" sprite filenames (IDs 0, 12, 29, etc.) +- Some entries have "null" sprite filenames when no visual is needed - Descriptions are in Korean (EUC-KR encoding) -- File uses Windows-style line endings (\r\n) -- IDs appear to be sequential but with some gaps (45-50, 64-66, etc.) -- **No copyrighted game content** is reproduced or distributed - -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game assets, sprite files, or proprietary content. All references to NPC types are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -### Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any SPR sprite files or game assets -- Uses **generic placeholders** for descriptions -- Focuses on **technical organization**, not creative content -- Maintains **nominal fair use** for trademark references +- File uses Windows-style line endings (`\r\n`) +- IDs may have gaps in the sequence ## Extractor -An extractor is available in `src/references/npc_ini.rs` to parse this file format. +The file is parsed by the `NpcIni` struct in `src/references/npc_ini.rs`, which implements the `Extractor` trait. ### How to Run ```bash # Extract Npc.ini to JSON -cargo run -- extract -i "fixtures/Dispel/Npc.ini" +cargo run -- extract -i "Npc.ini" # Import to SQLite database -cargo run -- database import "fixtures/Dispel/" "database.sqlite" +cargo run -- database import "path/to/Dispel/" "database.sqlite" ``` diff --git a/docs/files/NpcInGame/DlgMapFiles.dlg.md b/docs/files/NpcInGame/DlgMapFiles.dlg.md index 308b7ed5..0aa804b6 100644 --- a/docs/files/NpcInGame/DlgMapFiles.dlg.md +++ b/docs/files/NpcInGame/DlgMapFiles.dlg.md @@ -1,6 +1,10 @@ # Dlgcat1.dlg - Dialogue/Conversation Scripts +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information + - **Location**: `NpcInGame/Dlgcat1.dlg` (and other .dlg files) - **Format**: CSV with comments - **Encoding**: EUC-KR @@ -9,58 +13,75 @@ ## Structure ### File Format + - Lines starting with `;` are comments - CSV format with comma delimiter - Empty lines are ignored ### Record Structure + +Each line has exactly 10 comma-separated fields: + - `id`: i32 - Unique dialogue line identifier - `prev_event`: i32 - Required event ID to trigger - `next_dlg`: i32 - Next dialogue ID in chain - `type`: i32 - 0=normal, 1=choice dialog - `owner`: i32 - 0=player, 1=NPC - `dlg_id`: i32 - Reference to PGP text content +- `opt0`: i32 - Next dialogue option 1 (choice dialogs) or next dialog in linear type +- `opt1`: i32 - Next dialogue option 2 (choice dialogs); 0 = absent +- `opt2`: i32 - Next dialogue option 3 (choice dialogs); 0 = absent - `event_id`: i32 - Event triggered by dialogue ## Field Definitions + - `id`: Unique dialogue line identifier - `prev_event`: Required event ID to trigger - `next_dlg`: Next dialogue ID in chain - `type`: 0=normal, 1=choice dialog - `owner`: 0=player, 1=NPC - `dlg_id`: Reference to PGP text content +- `opt0`–`opt2`: Choice-dialog branching targets; unused options are 0 - `event_id`: Event triggered by dialogue ## Dialogue Types + - `0`: Normal dialogue (linear conversation) - `1`: Choice dialogue (branching options) ## Dialogue Owners + - `0`: Main character/player speaking - `1`: NPC character speaking ## Special Values -- "null" literal for optional fields + +- Optional fields are `0` when absent (not `"null"`) - Lines starting with `;` are comments - CSV format with comma delimiter +- Empty lines are ignored ## File Purpose -Defines dialogue scripts with branching conversations, event triggers, and text references. Used for NPC interactions, quest dialogues, and story progression systems. + +Defines dialogue scripts with branching conversations, event triggers, and text references. Used for NPC interactions, +quest dialogues, and story progression systems. ## Related Files + - `Dlgcat2.dlg`, `Dlgcat3.dlg`, `Dlgcatp.dlg` - `Dlgmap1.dlg`, `Dlgmap2.dlg`, `Dlgmap3.dlg` - `Dlgdun04.dlg`, `Dlgdun07.dlg`, `Dlgdun08.dlg`, `Dlgdun10.dlg`, `Dlgdun19.dlg`, `Dlgdun22.dlg` - `PartyDlg.dlg` ## Implementation -- **Rust Module**: `src/references/dialog.rs` -- **Extractor**: `Dialog` struct implementing `Extractor` trait + +- **Rust Module**: `src/references/dialogue_script.rs` +- **Extractor**: `DialogueScript` struct implementing `Extractor` trait - **Database**: Saved to SQLite via `save_dialogs` function ## Extractor -An extractor is available in `src/references/dialog.rs` to parse this file format. +An extractor is available in `src/references/dialogue_script.rs` to parse this file format. ### How to Run diff --git a/docs/files/NpcInGame/Eventnpc.ref.md b/docs/files/NpcInGame/Eventnpc.ref.md index 0bbf90c9..8d433e49 100644 --- a/docs/files/NpcInGame/Eventnpc.ref.md +++ b/docs/files/NpcInGame/Eventnpc.ref.md @@ -1,6 +1,10 @@ # Eventnpc.ref - Event NPC Definitions +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information + - **Location**: `NpcInGame/Eventnpc.ref` - **Format**: CSV with comments - **Encoding**: WINDOWS-1250 @@ -9,42 +13,43 @@ ## Structure ### File Format + - Lines starting with `;` are comments - CSV format with comma delimiter - Empty lines are ignored ### Record Structure + - `id`: i32 - Unique event NPC identifier - `event_id`: i32 - Linked event script ID - `name`: String - NPC display name ## Field Definitions + - `id`: Unique event NPC identifier - `event_id`: Linked event script ID - `name`: NPC display name -## NPC Categories -- 1-50: Quest-related NPCs -- 51-100: Story-critical NPCs -- 101-150: Temporary event NPCs -- 151-200: Special merchants/traders -- 201-250: Hidden/secret NPCs - ## Event Linking + - `event_id` links to Event.ini entries - NPCs appear only during specific events - Removed after event completion - Can trigger quests and dialogues ## Special Values + - Lines starting with `;` are comments - CSV format with comma delimiter - Empty lines ignored ## File Purpose -Defines NPCs that appear only during specific scripted events. Used for quest-related characters, temporary merchants, and story-critical encounters. + +Defines NPCs that appear only during specific scripted events. Used for quest-related characters, temporary merchants, +and story-critical encounters. ## Implementation + - **Rust Module**: `src/references/event_npc_ref.rs` - **Extractor**: `EventNpcRef` struct implementing `Extractor` trait - **Database**: Saved to SQLite via `save_event_npc_refs` function diff --git a/docs/files/NpcInGame/NpcMapFiles.ref.md b/docs/files/NpcInGame/NpcMapFiles.ref.md index 9700a7dc..3093faaa 100644 --- a/docs/files/NpcInGame/NpcMapFiles.ref.md +++ b/docs/files/NpcInGame/NpcMapFiles.ref.md @@ -1,54 +1,66 @@ # Npccat1.ref - NPC Placement Data +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information + - **Location**: `NpcInGame/Npccat1.ref` - **Format**: Binary (Little-Endian) - **Text Encoding**: WINDOWS-1250 -- **Record Size**: 672 bytes (0x2A0) +- **Record Size**: 672 bytes - **Header**: 4-byte record count ## Structure ### Header + - `record_count`: i32 (4 bytes) ### Record Structure (672 bytes) -| Offset | Field | Meaning | -|---:|---|---| -| 0x000 | file_record_id | File-local value; the map loader uses the record index for runtime identity. | -| 0x004 | npc_ini_id | NPC visual-archetype ID from `Npc.ini`. | -| 0x008 | name | 260-byte Windows-1250 display name. | -| 0x10c | role_description | 260-byte Windows-1250 role/description text. | -| 0x210 | party_member_slot | Values 1–8 select party/recruitment NPC behavior. | -| 0x214 | show_on_event | Event condition controlling visibility. | -| 0x218 | movement_mode | 0 static, 1 waypoint patrol, 2 random movement in activation rectangle. | -| 0x21c | goto1_filled..goto4_filled | Four waypoint-enabled flags. | -| 0x22c | goto1_x..goto4_x | Waypoint X coordinates. | -| 0x23c | goto1_y..goto4_y | Waypoint Y coordinates. | -| 0x24c | waypoint1_wait_time..waypoint4_wait_time | Delay at each waypoint. | -| 0x25c | waypoint1_facing_direction..waypoint4_facing_direction | Facing direction at each waypoint. | -| 0x26c | waypoint1_reserved..waypoint4_reserved | Per-waypoint reserved values, observed as zero. | -| 0x27c | activation_rect_x1..activation_rect_y2 | Rectangle used by random movement. | -| 0x28c | interaction_mode | 0 default, 1 random result, 2 configured result once then random. | -| 0x290 | interaction_result_item + interaction_result_parameter | Packed 32-bit interaction result. | -| 0x294 | interaction_range_offset | The game adds one before comparing interaction distance. | -| 0x298 | dialog_id | Dialogue node ID. | -| 0x29c | dialogue_face_sprite_id | Face sprite ID. | +| Offset | Field | Meaning | +|-------:|--------------------------------------------------------|------------------------------------------------------------------------------| +| 0 | file_record_id | File-local value; the map loader uses the record index for runtime identity. | +| 4 | npc_ini_id | NPC visual-archetype ID from `Npc.ini`. | +| 8 | name | 260-byte Windows-1250 display name. | +| 268 | role_description | 260-byte Windows-1250 role/description text. | +| 528 | party_member_slot | Values 1–8 select party/recruitment NPC behavior. | +| 532 | show_on_event | Event condition controlling visibility. | +| 536 | movement_mode | 0 static, 1 waypoint patrol, 2 random movement in activation rectangle. | +| 540 | goto1_filled..goto4_filled | Four waypoint-enabled flags. | +| 556 | goto1_x..goto4_x | Waypoint X coordinates. | +| 572 | goto1_y..goto4_y | Waypoint Y coordinates. | +| 588 | waypoint1_wait_time..waypoint4_wait_time | Delay at each waypoint. | +| 604 | waypoint1_facing_direction..waypoint4_facing_direction | Facing direction at each waypoint. | +| 620 | waypoint1_reserved..waypoint4_reserved | Per-waypoint reserved values, observed as zero. | +| 636 | activation_rect_x1..activation_rect_y2 | Rectangle used by random movement. | +| 652 | interaction_mode | 0 default, 1 random result, 2 configured result once then random. | +| 656 | interaction_result_item + interaction_result_parameter | Packed 32-bit interaction result. | +| 660 | interaction_range_offset | The game adds one before comparing interaction distance. | +| 664 | dialog_id | Dialogue node ID. | +| 668 | dialogue_face_sprite_id | Face sprite ID. | ## Looking Directions + - `0`: Up (North) -- `1`: Right (East) -- `2`: Down (South) -- `3`: Left (West) +- `1`: Up-Right (Northeast) +- `2`: Right (East) +- `3`: Down-Right (Southeast) +- `4`: Down (South) +- `5`: Down-Left (Southwest) +- `6`: Left (West) +- `7`: Up-Left (Northwest) ## Waypoint System + - 4 waypoints per NPC - `gotoN_filled`: 0=inactive, 1=active - `gotoN_x`/`gotoN_y`: Tile coordinates - Used for patrol routes and movement ## Special Values + - `show_on_event = 0`: Always visible - `show_on_event > 0`: Event-triggered - `dialog_id = 0`: No dialogue @@ -56,14 +68,18 @@ - Fixed 260-byte string fields ## File Purpose -Defines NPC placements with waypoints, dialogue, and behavioral parameters. Used for populating maps with interactive characters. + +Defines NPC placements with waypoints, dialogue, and behavioral parameters. Used for populating maps with interactive +characters. ## Related Files + - `Npccat2.ref`, `Npccat3.ref`, `Npccatp.ref` - `Npcmap1.ref`, `Npcmap2.ref`, `Npcmap3.ref` - `npcdun08.ref`, `npcdun19.ref` ## Implementation + - **Rust Module**: `src/references/npc_ref.rs` - **Extractor**: `NPC` struct implementing `Extractor` trait - **Database**: Saved to SQLite via `save_npc_refs` function diff --git a/docs/files/NpcInGame/PgpMapFiles.pgp.md b/docs/files/NpcInGame/PgpMapFiles.pgp.md index 44e1d42d..2db957cb 100644 --- a/docs/files/NpcInGame/PgpMapFiles.pgp.md +++ b/docs/files/NpcInGame/PgpMapFiles.pgp.md @@ -1,6 +1,10 @@ # Pgpcat1.pgp - Dialogue Text +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information + - **Location**: `NpcInGame/*.pgp` - **Format**: Commented pipe-delimited - **Encoding**: WINDOWS-1250 @@ -9,28 +13,34 @@ ## Structure ### File Format + - Lines starting with `;` are comments - Pipe-delimited format - Empty lines are ignored ### Record Structure + - `id`: i32 - Unique dialogue text identifier - `text`: String - Display text content -- `param1`: i32 - Logic parameter 1 -- `param2`: i32 - Logic parameter 2 +- `comment`: String - Developer notes (accumulated from preceding `;` comment lines, joined with ` | `) +- `param1`: i32 - Dialogue branch conditions +- `wave_ini_entry_id`: i32 - ID of sound from `Wave.ini`, played at start of dialogue ## Field Definitions + - `id`: Unique dialogue text identifier - `text`: Display text content -- `param1`: Logic parameter 1 -- `param2`: Logic parameter 2 +- `comment`: Developer notes (accumulated from preceding `;` comment lines) +- `param1`: Dialogue branch conditions +- `wave_ini_entry_id`: Sound effect ID from `Wave.ini` ## Parameter Usage + - `param1`: Dialogue branch conditions -- `param2`: Event triggers or requirements -- Special values: 0 = no condition +- `wave_ini_entry_id`: Sound effect played at dialogue start; 0 = none ## Text Formatting + - "null" literal for empty text - "$" literal interpreted as a line-break in game - Pipe (|) delimiter between fields @@ -38,28 +48,33 @@ - Multi-line comments supported ## Special Values + - `param1 = 0`: Unconditional dialogue -- `param2 = 0`: No event trigger -- Empty text: "null" literal -- Comment lines preserved with ";" prefix +- `wave_ini_entry_id = 0`: No sound trigger +- Empty text: `"null"` literal (mapped to empty string on parse) +- Comment lines preserved with `;` prefix ## File Purpose -Stores dialogue text content with developer comments and logical parameters. Used for displaying conversation text, branching dialogue, and triggering game events. + +Stores dialogue text content with developer comments and logical parameters. Used for displaying conversation text, +branching dialogue, and triggering game events. ## Related Files + - `Pgpcat2.pgp`, `Pgpcat3.pgp`, `Pgpcatp.pgp` - `Pgpmap1.pgp`, `Pgpmap2.pgp`, `Pgpmap3.pgp` - `Pgpdun04.pgp`, `Pgpdun07.pgp`, `Pgpdun08.pgp`, `Pgpdun10.pgp`, `Pgpdun19.pgp`, `Pgpdun22.pgp` - `PartyPgp.pgp` ## Implementation -- **Rust Module**: `src/references/dialogue_text.rs` -- **Extractor**: `DialogueText` struct implementing `Extractor` trait -- **Database**: Saved to SQLite via `save_dialogue_texts` function + +- **Rust Module**: `src/references/dialogue_paragraph.rs` +- **Extractor**: `DialogueParagraph` struct implementing `Extractor` trait +- **Database**: Saved to SQLite via `save_dialogue_paragraphs` function ## Extractor -An extractor is available in `src/references/dialogue_text.rs` to parse this file format. +An extractor is available in `src/references/dialogue_paragraph.rs` to parse this file format. ### How to Run diff --git a/docs/files/NpcInGame/PrtIni.db.md b/docs/files/NpcInGame/PrtIni.db.md index d3ae700e..e6f09570 100644 --- a/docs/files/NpcInGame/PrtIni.db.md +++ b/docs/files/NpcInGame/PrtIni.db.md @@ -1,30 +1,27 @@ # PrtIni.db -`NpcInGame/PrtIni.db` supplies the initial configuration for the eight -recruitable party-character slots in DISPEL®. +`NpcInGame/PrtIni.db` supplies the initial configuration for the eight recruitable party-character slots in DISPEL®. ## Format - No header. -- Exactly eight 28-byte (`0x1c`) records. -- The game reads record `party_slot * 0x1c` directly. -- `name` is a 20-byte, null-padded string. Shipped names are ASCII-compatible. +- Exactly eight 28-byte records. +- Records are addressed as `party_slot * 28`. +- `name` is a 20-byte, null-padded string. Names are ASCII-compatible. -| Offset | Size | Type | Field | Meaning | -|---:|---:|---|---|---| -| `0x00` | 20 | string | `name` | Character name | -| `0x14` | 1 | u8 | `reserved_0x14` | Reserved; zero in shipped records | -| `0x15` | 1 | u8 | `class_id` | Class identifier; shipped values 21–24 | -| `0x16` | 1 | u8 | `starting_level` | Level used when creating the character | -| `0x17` | 1 | u8 | `pathfinding_mode` | Map/path-query mode; shipped value 7 | -| `0x18` | 4 | u32 | `character_variant` | Variant selector; shipped values 0 and 1 | +| Offset | Size | Type | Field | Meaning | +|-------:|-----:|--------|---------------------|------------------------------------------| +| `0` | 20 | string | `name` | Character name | +| `20` | 1 | u8 | `reserved_0x14` | Reserved; zero by default | +| `21` | 1 | u8 | `class_id` | Class identifier; observed values 21–24 | +| `22` | 1 | u8 | `starting_level` | Level used when creating the character | +| `23` | 1 | u8 | `pathfinding_mode` | Map/path-query mode; observed value 7 | +| `24` | 4 | u32 | `character_variant` | Variant selector; observed values 0 and 1 | The loader copies `class_id`, `starting_level`, `pathfinding_mode`, and -`character_variant` into runtime character state. It uses `class_id` for -class-specific behavior and titles, and uses `character_variant` to choose one -of two class variants. The binary does not establish whether that variant is a -gender, portrait, or another presentation choice, so the parser uses the -neutral name `character_variant`. +`character_variant` into runtime character state. It uses `class_id` for class-specific behavior and titles, and uses +`character_variant` to choose one of two class variants. The binary does not establish whether that variant is a gender, +portrait, or another presentation choice, so the parser uses the neutral name `character_variant`. -DISPEL® is a registered trademark. This project is not affiliated with, -endorsed by, or sponsored by the trademark owner. +DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +owner. diff --git a/docs/files/NpcInGame/PrtLevel.db.md b/docs/files/NpcInGame/PrtLevel.db.md index 9bf1e2f1..b3f48685 100644 --- a/docs/files/NpcInGame/PrtLevel.db.md +++ b/docs/files/NpcInGame/PrtLevel.db.md @@ -1,37 +1,36 @@ # PrtLevel.db -`NpcInGame/PrtLevel.db` contains eight party-member progression tables, each -with 20 levels. It has no header: the game reads level `n` of party slot `s` -at `s * 0x2d0 + (n - 1) * 0x24`. +`NpcInGame/PrtLevel.db` contains eight party-member progression tables, each with 20 levels. It has no header: the game +reads level `n` of party slot `s` +at `s * 720 + (n - 1) * 36`. -Each entry is 36 bytes (`0x24`). +Each entry is 36 bytes. -| Offset | Size | Type | Field | Meaning | -|---:|---:|---|---|---| -| `0x00` | 1 | u8 | `magic_spell_id_1` | First magic-spell ID (`0xff` = absent) | -| `0x01` | 1 | u8 | `magic_spell_id_2` | Second magic-spell ID | -| `0x02` | 1 | u8 | `magic_spell_id_3` | Third magic-spell ID | -| `0x03` | 1 | u8 | `reserved_0x03` | Alignment byte | -| `0x04` | 4 | u32 | `strength` | Strength | -| `0x08` | 4 | u32 | `constitution` | Constitution | -| `0x0c` | 4 | u32 | `wisdom` | Wisdom | -| `0x10` | 2 | u16 | `health_points` | Maximum HP | -| `0x12` | 2 | u16 | `mana_points` | Maximum MP | -| `0x14` | 1 | u8 | `agility` | Agility | -| `0x15` | 3 | u8 | `reserved_0x15..17` | Reserved bytes | -| `0x18` | 1 | u8 | `attack` | Attack-related stat | -| `0x19` | 3 | u8 | `reserved_0x19..1b` | Reserved bytes | -| `0x1c` | 4 | u32 | `weapon_skill_level` | Party member's shared weapon proficiency | -| `0x20` | 4 | u32 | `tactical_action_chance` | Percentage threshold for a level-10+ tactical action | +| Offset | Size | Type | Field | Meaning | +|-------:|-----:|------|--------------------------|------------------------------------------------------| +| `0` | 1 | u8 | `magic_spell_id_1` | First magic-spell ID (255 = absent) | +| `1` | 1 | u8 | `magic_spell_id_2` | Second magic-spell ID | +| `2` | 1 | u8 | `magic_spell_id_3` | Third magic-spell ID | +| `3` | 1 | u8 | `reserved_0x03` | Alignment byte | +| `4` | 4 | u32 | `strength` | Strength | +| `8` | 4 | u32 | `constitution` | Constitution | +| `12` | 4 | u32 | `wisdom` | Wisdom | +| `16` | 2 | u16 | `health_points` | Maximum HP | +| `18` | 2 | u16 | `mana_points` | Maximum MP | +| `20` | 1 | u8 | `agility` | Agility | +| `21` | 3 | u8 | `reserved` (bytes 21–23) | Reserved bytes | +| `24` | 1 | u8 | `attack` | Attack-related stat | +| `25` | 3 | u8 | `reserved` (bytes 25–27) | Reserved bytes | +| `28` | 4 | u32 | `weapon_skill_level` | Party member's shared weapon proficiency | +| `32` | 4 | u32 | `tactical_action_chance` | Percentage threshold for a level-10+ tactical action | -The first three bytes were previously misidentified as a sentinel. They are -magic-spell references copied into runtime state. All -reserved bytes are preserved verbatim by the parser. +The first three bytes were previously misidentified as a sentinel. They are magic-spell references copied into runtime +state. All reserved bytes are preserved verbatim by the parser. -`weapon_skill_level` is not MP recharge: the game uses it in the same generic -weapon calculations that select one of the player's weapon-skill levels. -`tactical_action_chance` is not defence: it is compared to a random value from -0 to 99 before changing the party member's tactical action state. +`weapon_skill_level` is not MP recharge: the game uses it in the same generic weapon calculations that select one of the +player's weapon-skill levels. +`tactical_action_chance` is not defence: it is compared to a random value from 0 to 99 before changing the party +member's tactical action state. -DISPEL® is a registered trademark. This project is not affiliated with, -endorsed by, or sponsored by the trademark owner. +DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +owner. diff --git a/docs/files/Ref/DrawItem.db.md b/docs/files/Ref/DrawItem.db.md index c9bd21a3..89fae264 100644 --- a/docs/files/Ref/DrawItem.db.md +++ b/docs/files/Ref/DrawItem.db.md @@ -1,5 +1,8 @@ # DRAWITEM.ref +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## Purpose `DRAWITEM.ref` stores item placements for maps. @@ -15,28 +18,30 @@ (,,,) ``` -| Field | Type | Description | -|---|---|---| -| `map_id` | `i32` | Target map identifier. | -| `x_coord` | `i32` | Map X coordinate. | -| `y_coord` | `i32` | Map Y coordinate. | +| Field | Type | Description | +|----------------|-------|------------------------| +| `map_id` | `i32` | Target map identifier. | +| `x_coord` | `i32` | Map X coordinate. | +| `y_coord` | `i32` | Map Y coordinate. | | `encoded_item` | `i32` | Packed item reference. | ## Item Encoding `encoded_item` is an `InventoryItem` value. -| Bytes | Meaning | -|---|---| -| `1` | Item ID. | -| `2` | Item type. | +| Bytes | Meaning | +|-------|-------------------------------------------| +| `1` | Item ID. | +| `2` | Item type. | | `3-4` | Preserved as part of the raw `i32` value. | -The parser keeps the complete encoded value in `DrawItem::item`. The editor uses `CompositeItem` to edit its item ID and type together. +The parser keeps the complete encoded value in `DrawItem::item`. The editor uses `CompositeItem` to edit its item ID and +type together. ## Parser Behavior -The parser ignores empty rows, comment rows, malformed rows, and rows that do not have exactly four fields. It writes normalized CRLF line endings. +The parser ignores empty rows, comment rows, malformed rows, and rows that do not have exactly four fields. It writes +normalized CRLF line endings. The Rust parser is [draw_item.rs](../../../src/references/draw_item.rs). diff --git a/docs/files/Ref/Map.ini.md b/docs/files/Ref/Map.ini.md index dc40bb0b..e51e33f9 100644 --- a/docs/files/Ref/Map.ini.md +++ b/docs/files/Ref/Map.ini.md @@ -1,5 +1,8 @@ # Map.ini Documentation +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + ## File Information - **Location**: `Dispel/Ref/Map.ini` (relative to game installation directory) @@ -7,9 +10,11 @@ - **Format**: CSV (Comma-Separated Values) - **Comment lines**: Lines starting with `;` are ignored -`Map.ini` is a map initialization data file used by the game engine to configure individual map properties, starting positions, and associated resources. +`Map.ini` is a map initialization data file used by the game engine to configure individual map properties, starting +positions, and associated resources. ## Structure + Each line represents one map initialization record with the following fields: ``` @@ -17,17 +22,18 @@ id,camera_event,start_x,start_y,map_id,monsters,npcs,extras,cd_track ``` ### Field Definitions -| Field | Type | Description | -|-------|------|-------------| -| `id` | Integer | Unique map identifier | -| `camera_event` | Integer | Event ID triggered when camera moves | -| `start_x` | Integer | Initial player X coordinate (isometric tiles) | -| `start_y` | Integer | Initial player Y coordinate (isometric tiles) | -| `map_id` | Integer | Target map ID for linking/transition | -| `monsters` | String/Null | Monster placement REF filename or "null" | -| `npcs` | String/Null | NPC placement REF filename or "null" | -| `extras` | String/Null | Extra interactive objects REF filename or "null" | -| `cd_track` | Integer | Background music CD track number | + +| Field | Type | Description | +|----------------|-------------|--------------------------------------------------| +| `id` | Integer | Unique map identifier | +| `camera_event` | Integer | Event ID triggered when camera moves | +| `start_x` | Integer | Initial player X coordinate (isometric tiles) | +| `start_y` | Integer | Initial player Y coordinate (isometric tiles) | +| `map_id` | Integer | Target map ID for linking/transition | +| `monsters` | String/Null | Monster placement REF filename or "null" | +| `npcs` | String/Null | NPC placement REF filename or "null" | +| `extras` | String/Null | Extra interactive objects REF filename or "null" | +| `cd_track` | Integer | Background music CD track number | ## Example Entries @@ -43,13 +49,16 @@ id,camera_event,start_x,start_y,map_id,monsters,npcs,extras,cd_track ``` ## Special Values + - **"null"**: Used for `monsters`, `npcs`, and `extras` fields when the file is absent - **Comments**: Lines starting with `;` are ignored - **Coordinates**: Use isometric tile coordinate system - **Event IDs**: Special event numbers that trigger when camera moves ## Purpose + This file defines initialization parameters for each map including: + 1. Starting player positions (X,Y coordinates) 2. Linked resource files (monsters, NPCs, extra objects) 3. Background music track selection @@ -57,35 +66,22 @@ This file defines initialization parameters for each map including: 5. Camera movement event triggers The game engine uses this data during map loading to: + - Position the player correctly - Load appropriate monster/NPC placements - Set up interactive objects - Play the correct background music - Handle map transitions and camera events -## Legal Notice - -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, map data, or proprietary assets. All references to map configurations are for **educational and research purposes** to document file organization and data structures. - -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. - -### Legal Compliance - -This documentation: -- Describes **file format specifications only** -- Does **not** distribute any map data or game content -- Focuses on **technical organization**, not creative content -- Uses **generic descriptions** of file purposes -- Maintains **nominal fair use** for trademark references - ## Notes + - The file is parsed by the `MapIni` struct in `src/references/map_ini.rs` - Map initialization records are stored in a database using the `save_map_inis` function -- The file format is strictly enforced with EUC-KR encoding +- The file format uses EUC-KR encoding - Coordinate system uses isometric tiles, not pixel coordinates -- **No copyrighted game content** is reproduced or distributed ## Related Files Location + - **Monster placement files** (`mon*.ref`): `Dispel/MonsterInGame/` directory - **NPC placement files** (`npc*.ref`): `Dispel/NpcInGame/` directory - **Extra objects files** (`Ext*.ref`): `Dispel/ExtraInGame/` directory diff --git a/docs/files/Ref/PartyRef.ref.md b/docs/files/Ref/PartyRef.ref.md index 7f7cc35c..9c9b6104 100644 --- a/docs/files/Ref/PartyRef.ref.md +++ b/docs/files/Ref/PartyRef.ref.md @@ -1,6 +1,10 @@ -# PartyRef.ref - Party Characters +# PartyRef.ref — Party Characters + +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. ## File Information + - **Location**: `Ref/PartyRef.ref` - **Format**: CSV with comments - **Encoding**: WINDOWS-1250 @@ -9,11 +13,13 @@ ## File Structure ### File Format + - Lines starting with `;` are comments - CSV format with comma delimiter - Empty lines are ignored ### Record Structure + - `id`: i32 - Unique character identifier - `name`: String - Character display name or "null" - `job`: String - Character class/job or "null" @@ -26,55 +32,65 @@ ## Field Definitions ### id + - Unique character identifier - Party member index - Used for character selection and management ### name + - Character display name - Shown in UI and dialogues - "null" for unnamed characters - WINDOWS-1250 encoding for special characters ### map_id + - Origin map identifier - Where character is initially found - Links to map files and locations - Determines recruitment location ### npc_id + - Linked NPC record ID - Connects to NPC definitions - Determines appearance and behavior - Links to `Npc.ini` entries ### dlg_out + - Dialog ID when not in party - Conversation when character is roaming - Recruitment dialogue - Initial interaction text ### dlg_in + - Dialog ID when in party - Conversation when character is recruited - Party member dialogue - Ongoing interaction text ### in_party + - Boolean flag (0/1) marking the character as currently in the party -- The game reads this as a single byte and uses it to highlight the character's - name in the party member list when non-zero +- A single byte; non-zero marks the character's name as highlighted in the party member list - `0`: character not in the party (default) - `1`: character is in the party (name shown highlighted) ## Special Values + - `"null"` literal for missing name/job fields - Lines starting with `;` are comments - CSV format with comma delimiter - Empty lines ignored ## File Purpose -Defines all party characters with their names, classes, origin locations, dialog references, and visual representations. Used for: + +Defines all party characters with their names, classes, origin locations, dialog references, and visual representations. +Used for: + - Party management system - Character recruitment - Dialogue interactions @@ -82,6 +98,7 @@ Defines all party characters with their names, classes, origin locations, dialog - Character progression tracking ## Implementation + - **Rust Module**: `src/references/party_ref.rs` - **Extractor**: `PartyRef` struct implementing `Extractor` trait - **Data Structure**: `PartyRef` with character attributes @@ -90,11 +107,13 @@ Defines all party characters with their names, classes, origin locations, dialog ## Example Usage ### Extract and display party characters: + ```bash cargo run -- extract -i "fixtures/Dispel/Ref/PartyRef.ref" ``` ### Format Example + ``` ; Party character definitions ; id,name,job,map_id,npc_id,dlg_out,dlg_in,in_party @@ -104,88 +123,14 @@ cargo run -- extract -i "fixtures/Dispel/Ref/PartyRef.ref" 4,Rogue,Thief,3,4,106,107,0 ``` -## Character Classes - -### Warrior Types -- Fighter: Melee combat specialist -- Knight: Heavy armor, high defense -- Berserker: High damage, low defense -- Paladin: Holy warrior, balanced - -### Mage Types -- Sorcerer: Offensive magic -- Cleric: Healing and support -- Necromancer: Dark magic -- Elementalist: Element-based spells - -### Rogue Types -- Thief: Stealth and agility -- Assassin: Critical hits -- Ranger: Ranged combat -- Bard: Support and buffs - -### Hybrid Types -- Battle Mage: Magic and melee -- Spellblade: Sword and spell combo -- Monk: Unarmed combat -- Druid: Nature magic - -## Party Management - -### Recruitment -- Characters found at specific locations -- Dialogue-based recruitment -- Quest requirements may apply -- Limited party size - -### Dialogue System -- Different dialogues based on party status -- Context-sensitive conversations -- Character-specific interactions -- Story progression through dialogue - -### UI Integration -- Character portraits in menus -- Status displays -- Inventory management -- Skill and ability interfaces - -## Game Mechanics - -### Character Progression -- Experience gain and leveling -- Skill development -- Equipment and gear -- Stat growth and improvement - -### Party Dynamics -- Character relationships -- Synergy and combinations -- Conflict resolution -- Group strategies - -### Quest Integration -- Character-specific quests -- Party-based objectives -- Story-driven interactions -- Reward distribution - ## Related Files -- `Npc.ini` - NPC definitions -- `PrtIni.db` - Party initialization data -- `PrtLevel.db` - Character progression data + +- `Npc.ini` — NPC definitions +- `PrtIni.db` — Party initialization data +- `PrtLevel.db` — Character progression data - Dialogue files (`.dlg`) - Map files (`.map`) -## Data Analysis -The file enables analysis of: -- Character distribution and balance -- Class representation -- Recruitment patterns -- Dialogue coverage -- Party composition options -- Character progression paths - ## Extractor An extractor is available in `src/references/party_ref.rs` to parse this file format. diff --git a/docs/files/SaveFile.md b/docs/files/SaveFile.md index 13612489..71aeec7e 100644 --- a/docs/files/SaveFile.md +++ b/docs/files/SaveFile.md @@ -1,5 +1,8 @@ # Save File Documentation +> DISPEL® is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## File Information - **Location**: `Dispel/Save/` directory (relative to game installation directory) @@ -7,73 +10,76 @@ - **Format**: Binary (little-endian) - **Extension**: `.sav` (or similar save file extension) -The save file is a binary serialization of the player's game state, including character stats, inventory, party members, journal entries, and map-specific data. +The save file is a binary serialization of the player's game state, including character stats, inventory, party members, +journal entries, and map-specific data. ## Structure -The save file is parsed sequentially from start to end. The top-level structure is defined by the `SaveFile` struct in `src/references/save_file/mod.rs`. +The save file is parsed sequentially from start to end. The top-level structure is defined by the `SaveFile` struct in +`src/references/save_file/mod.rs`. ### Top-Level Layout -| Offset | Field | Type | Description | -|--------|-------|------|-------------| -| 0 | `game_tmp_blob_size` | `u32` | Jump address after all map data (first 4 bytes of the file) | -| 4 | `number_of_visited_maps` | `u32` | Count of map sections that follow | -| 8 | `maps` | `Vec` | Per-map world state (variable length) | -| — | `post_maps` | `PostMapsData` | Save-world header and map viewport state | -| — | `map_viewport_state` | `MapViewportState` | Fixed-size isometric map viewport state (10,148 bytes) | -| — | `sprite_paths` | `Vec` | Character sprite paths (4 × 60 bytes) | -| — | `character` | `CharacterData` | Character stats, position, and runtime state (112 bytes) | -| — | `inventory` | `InventoryData` | Raw inventory data (5 item categories) | -| — | `character_identity` | `CharacterIdentity` | Character name, class, and spell-bar state | -| — | `inventory_slots` | `InventorySlots` | Equipment, belt, and inventory placement state | -| — | `learned_spells` | `LearnedSpells` | One flag per learned spell (41 bytes) | -| — | `party_members_count` | `u32` | Serialized party-member count | -| — | `party_members` | `Vec` | Recruited party members (variable length) | -| — | `events` | `Vec` | Event scripts (2,251 × 284 bytes) | -| — | `post_events` | `PostEventsData` | Character walk-event log between events and journal | -| — | `journal` | `JournalData` | Journal entries (42-byte header + 3 × 100 entries) | +| Offset | Field | Type | Description | +|--------|--------------------------|-----------------------|-------------------------------------------------------------| +| 0 | `game_tmp_blob_size` | `u32` | Jump address after all map data (first 4 bytes of the file) | +| 4 | `number_of_visited_maps` | `u32` | Count of map sections that follow | +| 8 | `maps` | `Vec` | Per-map world state (variable length) | +| — | `post_maps` | `PostMapsData` | Save-world header and map viewport state | +| — | `map_viewport_state` | `MapViewportState` | Fixed-size isometric map viewport state (10,148 bytes) | +| — | `sprite_paths` | `Vec` | Character sprite paths (4 × 60 bytes) | +| — | `character` | `CharacterData` | Character stats, position, and runtime state (112 bytes) | +| — | `inventory` | `InventoryData` | Raw inventory data (5 item categories) | +| — | `character_identity` | `CharacterIdentity` | Character name, class, and spell-bar state | +| — | `inventory_slots` | `InventorySlots` | Equipment, belt, and inventory placement state | +| — | `learned_spells` | `LearnedSpells` | One flag per learned spell (41 bytes) | +| — | `party_members_count` | `u32` | Serialized party-member count | +| — | `party_members` | `Vec` | Recruited party members (variable length) | +| — | `events` | `Vec` | Event scripts (2,251 × 284 bytes) | +| — | `post_events` | `PostEventsData` | Character walk-event log between events and journal | +| — | `journal` | `JournalData` | Journal entries (42-byte header + 3 × 100 entries) | ## Section Details ### Header The first 8 bytes contain: -1. `game_tmp_blob_size` (4 bytes, `u32`): A jump address pointing to the byte offset after all map data. The reader uses this to seek past the map sections if needed. + +1. `game_tmp_blob_size` (4 bytes, `u32`): A jump address pointing to the byte offset after all map data. The reader uses + this to seek past the map sections if needed. 2. `number_of_visited_maps` (4 bytes, `u32`): The number of `MapSectionData` records that follow. ### Map Sections (`maps`) Each visited map is serialized as a `MapSectionData` record containing: -| Field | Type | Description | -|-------|------|-------------| -| `map_id` | `u32` | Map index/ID referenced in AllMap.ini | -| `monsters` | `Vec` | Monsters present on this map (329 bytes each) | -| `npcs` | `Vec` | NPCs present on this map (349 bytes each) | -| `extra_objects` | `Vec` | Extra objects like chests, doors, triggers (200 bytes each) | -| `extra_objects_trailer` | `MapExtraObjectsTrailer` | Ground-item manager data | -| `draw_items_weapon` | `Vec` | Ground items — Weapon type (296 bytes each) | -| `draw_items_heal` | `Vec` | Ground items — Heal type (264 bytes each) | -| `draw_items_edit` | `Vec` | Ground items — Edit type (280 bytes each) | -| `draw_items_misc` | `Vec` | Ground items — Misc type (268 bytes each) | -| `draw_items_event` | `Vec` | Ground items — Event type (252 bytes each) | +| Field | Type | Description | +|-------------------------|---------------------------|-------------------------------------------------------------| +| `map_id` | `u32` | Map index/ID referenced in AllMap.ini | +| `monsters` | `Vec` | Monsters present on this map (329 bytes each) | +| `npcs` | `Vec` | NPCs present on this map (349 bytes each) | +| `extra_objects` | `Vec` | Extra objects like chests, doors, triggers (200 bytes each) | +| `extra_objects_trailer` | `MapExtraObjectsTrailer` | Ground-item manager data | +| `draw_items_weapon` | `Vec` | Ground items — Weapon type (296 bytes each) | +| `draw_items_heal` | `Vec` | Ground items — Heal type (264 bytes each) | +| `draw_items_edit` | `Vec` | Ground items — Edit type (280 bytes each) | +| `draw_items_misc` | `Vec` | Ground items — Misc type (268 bytes each) | +| `draw_items_event` | `Vec` | Ground items — Event type (252 bytes each) | Each collection is prefixed with a count (`u32` for monsters/NPCs/extra objects, `u16` for ground items). -Each ground-item record ends with a one-based `u16` ground-object ID and two preserved padding bytes. -The item category determines the ID range used by the game. +Each ground-item record ends with a one-based `u16` ground-object ID and two preserved padding bytes. The item category +determines the ID range used by the game. -An NPC record stores an active path-step direction at byte 161 and its animation frame at byte 162. -The direction values are `0=(0,+32)`, `1=(-32,+16)`, `2=(-64,0)`, `3=(-32,-16)`, -`4=(0,-32)`, `5=(+32,-16)`, `6=(+64,0)`, `7=(+32,+16)`, and `255=inactive`. -The pairs are screen-space offsets from the destination cell. +An NPC record stores an active path-step direction at byte 161 and its animation frame at byte 162. The direction values +are `0=(0,+32)`, `1=(-32,+16)`, `2=(-64,0)`, `3=(-32,-16)`, +`4=(0,-32)`, `5=(+32,-16)`, `6=(+64,0)`, `7=(+32,+16)`, and `255=inactive`. The pairs are screen-space offsets from the +destination cell. -Monster runtime data includes the current combat target, timed status-effect -type and duration, movement-animation frame and offsets, path-buffer length and -index, and temporary visual effects. Temporary visuals have separate active, -frame, and duration fields for status, ground, special-attack, guard, blood, -and timed-overlay effects. Blood-effect directions use values `0..=7`. +Monster runtime data includes the current combat target, timed status-effect type and duration, movement-animation frame +and offsets, path-buffer length and index, and temporary visual effects. Temporary visuals have separate active, frame, +and duration fields for status, ground, special-attack, guard, blood, and timed-overlay effects. Blood-effect directions +use values `0..=7`. The monster status-effect type uses `0=none`; observed active values are `1`, `2`, `3`, `4`, `7`, and `12`. Its parameter is `-1` when unused. Type `3` @@ -88,55 +94,54 @@ NPC runtime flags use these values: - `player_interaction_latched`: `0=not interacting`, `1=interaction started`. - `start_dialogue_on_arrival`: `0=normal arrival`, `1=start arrival dialogue`. -The arrival dialogue ID follows the arrival-action flag. Two intervening runtime -words are reserved, initialized to zero, and preserved without interpretation. +The arrival dialogue ID follows the arrival-action flag. Two intervening runtime words are reserved, initialized to +zero, and preserved without interpretation. -An extra-object record begins with its selected render state, render variant, -and current sprite frame. It ends with the activation effect ID, one reserved -byte, two padding bytes, the overlay flag, the map-active flag, and the pending -interaction latch. Boolean controls use `0=disabled` and non-zero `enabled`; -the pending latch uses `0=no request` and `1=activation requested`. +An extra-object record begins with its selected render state, render variant, and current sprite frame. It ends with the +activation effect ID, one reserved byte, two padding bytes, the overlay flag, the map-active flag, and the pending +interaction latch. Boolean controls use `0=disabled` and non-zero `enabled`; the pending latch uses `0=no request` and +`1=activation requested`. ### Post-Maps Data (`post_maps`) Contains save-world header values and the list of visited map IDs: -| Field | Type | Description | -|-------|------|-------------| -| `map_section_terminator` | `u32` | Terminator after the final map section (known saves store zero) | -| `game_version` | `f32` | Save-format version (observed as 1.45) | -| `all_map_ini_id` | `u32` | `AllMap.ini.id` for the loaded map's files, geometry, name, dialogue, and lighting | -| `ref_map_ini_id` | `u32` | `Ref/Map.ini.id` for the entrance configuration, including spawn coordinates and placement files | -| `reserved_header_word` | `u32` | Reserved header word (observed as zero) | -| `monster_block_size` | `u32` | Size of a MonsterRecord (329 in known saves) | -| `npc_block_size` | `u32` | Size of an NpcRecord (349 in known saves) | -| `unused_map_object_block_size` | `u32` | Record size for an unused map-object section (observed as zero) | -| `extra_object_block_size` | `u32` | Size of an ExtraObjectRecord (200 in known saves) | -| `number_of_visited_maps` | `u32` | Number of visited maps (must match the map section count) | -| `map_ids` | `Vec` | IDs of the visited maps | - -The two map identifiers address different tables. `all_map_ini_id` selects the -map itself. `ref_map_ini_id` selects the route or entrance used to initialize -that map. Multiple `Ref/Map.ini` records can target the same `AllMap.ini` map -while providing different spawn coordinates. +| Field | Type | Description | +|--------------------------------|------------|--------------------------------------------------------------------------------------------------| +| `map_section_terminator` | `u32` | Terminator after the final map section (known saves store zero) | +| `game_version` | `f32` | Save-format version (observed as 1.45) | +| `all_map_ini_id` | `u32` | `AllMap.ini.id` for the loaded map's files, geometry, name, dialogue, and lighting | +| `ref_map_ini_id` | `u32` | `Ref/Map.ini.id` for the entrance configuration, including spawn coordinates and placement files | +| `reserved_header_word` | `u32` | Reserved header word (observed as zero) | +| `monster_block_size` | `u32` | Size of a MonsterRecord (329 in known saves) | +| `npc_block_size` | `u32` | Size of an NpcRecord (349 in known saves) | +| `unused_map_object_block_size` | `u32` | Record size for an unused map-object section (observed as zero) | +| `extra_object_block_size` | `u32` | Size of an ExtraObjectRecord (200 in known saves) | +| `number_of_visited_maps` | `u32` | Number of visited maps (must match the map section count) | +| `map_ids` | `Vec` | IDs of the visited maps | + +The two map identifiers address different tables. `all_map_ini_id` selects the map itself. `ref_map_ini_id` selects the +route or entrance used to initialize that map. Multiple `Ref/Map.ini` records can target the same `AllMap.ini` map while +providing different spawn coordinates. ### Map Viewport State (`map_viewport_state`) A fixed-size (10,148 bytes) serialized state of the game's isometric map viewport: -| Field | Type | Description | -|-------|------|-------------| -| `viewport_clip_rect` | `MapViewportRect` | Fixed screen rectangle in which the map is drawn | -| `map_projection_rect` | `MapViewportRect` | Projected map rectangle translated while the camera scrolls | -| `camera_boundary_tiles` | `[MapTileReference; 8]` | Tile coordinates and row-major indices used to constrain camera movement | -| `cells` | `Vec` | Cached screen-to-map lookup cells (500 entries, 20 bytes each) | -| `scroll_direction` | `i32` | Smooth-scroll direction: `-1`=idle, `0`=up, `1`=up-right, `2`=right, `3`=down-right, `4`=down, `5`=down-left, `6`=left, `7`=up-left | -| `smooth_scroll_offset_x` | `u32` | Accumulated horizontal sub-tile scroll offset | -| `smooth_scroll_offset_y` | `u32` | Accumulated vertical sub-tile scroll offset | -| `scroll_animation_frame` | `u32` | Current smooth-scroll animation frame | -| `scroll_animation_frame_count` | `u32` | Total frames in the active smooth-scroll animation | +| Field | Type | Description | +|--------------------------------|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `viewport_clip_rect` | `MapViewportRect` | Fixed screen rectangle in which the map is drawn | +| `map_projection_rect` | `MapViewportRect` | Projected map rectangle translated while the camera scrolls | +| `camera_boundary_tiles` | `[MapTileReference; 8]` | Tile coordinates and row-major indices used to constrain camera movement | +| `cells` | `Vec` | Cached screen-to-map lookup cells (500 entries, 20 bytes each) | +| `scroll_direction` | `i32` | Smooth-scroll direction: `-1`=idle, `0`=up, `1`=up-right, `2`=right, `3`=down-right, `4`=down, `5`=down-left, `6`=left, `7`=up-left | +| `smooth_scroll_offset_x` | `u32` | Accumulated horizontal sub-tile scroll offset | +| `smooth_scroll_offset_y` | `u32` | Accumulated vertical sub-tile scroll offset | +| `scroll_animation_frame` | `u32` | Current smooth-scroll animation frame | +| `scroll_animation_frame_count` | `u32` | Total frames in the active smooth-scroll animation | Each `MapViewportCell` contains: + - `screen_x` (`u32`): Screen X coordinate - `screen_y` (`u32`): Screen Y coordinate - `map_x` (`u32`): Map tile X coordinate @@ -151,129 +156,130 @@ Four fixed-size (60-byte) null-terminated WINDOWS-1250 strings representing char 112 bytes containing character position, stats, and runtime state: -| Field | Type | Description | -|-------|------|-------------| -| `script_event_active` | `u32` | Whether a script/event is running | -| `armor_display_mode` | `u32` | Armor rendering mode by gender | -| `character_position_x` | `i16` | Character X position | -| `character_position_y` | `i16` | Character Y position | -| `is_moving` | `u8` | Whether the character is moving | -| `tile_value_under_player` | `u8` | Ground tile ID under the player | -| `busy_event_flag` | `u8` | Non-combat interaction flag | -| `target_tile_value` | `u8` | Target tile value during movement | -| `sub_action_state` | `u8` | Movement sub-state (0=idle, 1=start, 2=walking, 3=action) | -| `selected_spell_id` | `u8` | Currently selected spell | -| `attack_anim_frame` | `i16` | Attack animation frame | -| `attack_action_state` | `u8` | Attack/cast state | -| `action_mode` | `u8` | Transient action mode selector | -| `anim_frame_delay_counter` | `u8` | Animation frame delay counter | -| `anim_frame_delay_threshold` | `u8` | Animation frame delay threshold | -| `movement_state` | `u8` | Movement mode (0=idle, 1=running, 2=walking, 3=special) | -| `animation_frame` | `u8` | Current animation frame index | -| `level_up_pending` | `u8` | Level-up pending flag | -| `elapsed_frame_counter` | `i16` | Held action frame counter | -| `hit_buildup_counter` | `i16` | Rapid hit counter | -| `selected_action_index` | `i16` | Selected action index | -| `clickable_item_count` | `u8` | Count of clickable map entities | -| `active_status_effect` | `u8` | Active status effect | -| `pending_status_effect` | `u8` | Pending status effect | -| `poison_tick_interval` | `i16` | Poison damage tick interval | -| `poison_tick_counter` | `i16` | Poison damage tick counter | -| `strength` | `u16` | Strength stat | -| `agility` | `u16` | Agility stat | -| `wisdom` | `u16` | Wisdom stat | -| `constitution` | `u16` | Constitution stat | -| `morale` | `u16` | Morale stat | -| `hp_current` | `u16` | Current HP | -| `hp_max` | `u16` | Maximum HP | -| `mp_current` | `u16` | Current MP | -| `mp_max` | `u16` | Maximum MP | -| `xp` | `u32` | Experience points | -| `level` | `u8` | Character level | -| `unspent_stat_points` | `u8` | Unspent stat points | -| `gold` | `u32` | Gold amount | -| `offense` | `u16` | Offense stat | -| `defense` | `u16` | Defense stat | -| `dodge` | `u8` | Dodge rate | -| `hit` | `u8` | Hit rate | -| `magic_power` | `u16` | Magic power | -| `attack_mod` | `u8` | Attack modifier | -| `thievery` | `u8` | Pickpocketing skill | -| `lockpick` | `u8` | Lockpicking skill | -| `haggle` | `u8` | Haggling skill | -| `perception` | `u8` | Perception skill | -| `traps` | `u8` | Traps skill | -| `sword_lv` | `u8` | Swords skill level | -| `sword_kills` | `u16` | Swords kills | -| `axe_lv` | `u8` | Axes skill level | -| `axe_kills` | `u16` | Axes kills | -| `archery_lv` | `u8` | Archery skill level | -| `archery_kills` | `u16` | Archery kills | -| `polearm_lv` | `u8` | Polearm skill level | -| `polearm_kills` | `u16` | Polearm kills | -| `magic_lv` | `u8` | Magic skill level | -| `magic_kills` | `u16` | Magic kills | -| `holy_lv` | `u8` | Holy magic skill level | -| `holy_kills` | `u16` | Holy magic kills | -| `dark_lv` | `u8` | Dark magic skill level | -| `dark_kills` | `u16` | Dark magic kills | -| `cached_tile_value` | `u16` | Cached pathfinder tile value | -| `combat_action_state` | `u8` | Combat action state | -| `reserved_05c` | `u16` | Reserved | -| `reserved_05d` | `u16` | Reserved | -| `ui_hover_active` | `u8` | UI hover active flag | -| `status_effect_stack` | `u8` | Status effect stack count | +| Field | Type | Description | +|------------------------------|-------|-----------------------------------------------------------| +| `script_event_active` | `u32` | Whether a script/event is running | +| `armor_display_mode` | `u32` | Armor rendering mode by gender | +| `character_position_x` | `i16` | Character X position | +| `character_position_y` | `i16` | Character Y position | +| `is_moving` | `u8` | Whether the character is moving | +| `tile_value_under_player` | `u8` | Ground tile ID under the player | +| `busy_event_flag` | `u8` | Non-combat interaction flag | +| `target_tile_value` | `u8` | Target tile value during movement | +| `sub_action_state` | `u8` | Movement sub-state (0=idle, 1=start, 2=walking, 3=action) | +| `selected_spell_id` | `u8` | Currently selected spell | +| `attack_anim_frame` | `i16` | Attack animation frame | +| `attack_action_state` | `u8` | Attack/cast state | +| `action_mode` | `u8` | Transient action mode selector | +| `anim_frame_delay_counter` | `u8` | Animation frame delay counter | +| `anim_frame_delay_threshold` | `u8` | Animation frame delay threshold | +| `movement_state` | `u8` | Movement mode (0=idle, 1=running, 2=walking, 3=special) | +| `animation_frame` | `u8` | Current animation frame index | +| `level_up_pending` | `u8` | Level-up pending flag | +| `elapsed_frame_counter` | `i16` | Held action frame counter | +| `hit_buildup_counter` | `i16` | Rapid hit counter | +| `selected_action_index` | `i16` | Selected action index | +| `clickable_item_count` | `u8` | Count of clickable map entities | +| `active_status_effect` | `u8` | Active status effect | +| `pending_status_effect` | `u8` | Pending status effect | +| `poison_tick_interval` | `i16` | Poison damage tick interval | +| `poison_tick_counter` | `i16` | Poison damage tick counter | +| `strength` | `u16` | Strength stat | +| `agility` | `u16` | Agility stat | +| `wisdom` | `u16` | Wisdom stat | +| `constitution` | `u16` | Constitution stat | +| `morale` | `u16` | Morale stat | +| `hp_current` | `u16` | Current HP | +| `hp_max` | `u16` | Maximum HP | +| `mp_current` | `u16` | Current MP | +| `mp_max` | `u16` | Maximum MP | +| `xp` | `u32` | Experience points | +| `level` | `u8` | Character level | +| `unspent_stat_points` | `u8` | Unspent stat points | +| `gold` | `u32` | Gold amount | +| `offense` | `u16` | Offense stat | +| `defense` | `u16` | Defense stat | +| `dodge` | `u8` | Dodge rate | +| `hit` | `u8` | Hit rate | +| `magic_power` | `u16` | Magic power | +| `attack_mod` | `u8` | Attack modifier | +| `thievery` | `u8` | Pickpocketing skill | +| `lockpick` | `u8` | Lockpicking skill | +| `haggle` | `u8` | Haggling skill | +| `perception` | `u8` | Perception skill | +| `traps` | `u8` | Traps skill | +| `sword_lv` | `u8` | Swords skill level | +| `sword_kills` | `u16` | Swords kills | +| `axe_lv` | `u8` | Axes skill level | +| `axe_kills` | `u16` | Axes kills | +| `archery_lv` | `u8` | Archery skill level | +| `archery_kills` | `u16` | Archery kills | +| `polearm_lv` | `u8` | Polearm skill level | +| `polearm_kills` | `u16` | Polearm kills | +| `magic_lv` | `u8` | Magic skill level | +| `magic_kills` | `u16` | Magic kills | +| `holy_lv` | `u8` | Holy magic skill level | +| `holy_kills` | `u16` | Holy magic kills | +| `dark_lv` | `u8` | Dark magic skill level | +| `dark_kills` | `u16` | Dark magic kills | +| `cached_tile_value` | `u16` | Cached pathfinder tile value | +| `combat_action_state` | `u8` | Combat action state | +| `reserved_05c` | `u16` | Reserved | +| `reserved_05d` | `u16` | Reserved | +| `ui_hover_active` | `u8` | UI hover active flag | +| `status_effect_stack` | `u8` | Status effect stack count | ### Character Identity (`character_identity`) 35 bytes containing character name, class, and spell-bar state: -| Field | Type | Description | -|-------|------|-------------| -| `player_name` | String (11 bytes) | Player name (WINDOWS-1250, null-terminated) | -| `player_class_id` | `u16` | Player class ID | -| `player_class_name` | String (20 bytes) | Player class name (WINDOWS-1250, null-terminated) | -| `selected_spell_ui_index` | `u16` | UI index of selected spell | +| Field | Type | Description | +|---------------------------|-------------------|---------------------------------------------------| +| `player_name` | String (11 bytes) | Player name (WINDOWS-1250, null-terminated) | +| `player_class_id` | `u16` | Player class ID | +| `player_class_name` | String (20 bytes) | Player class name (WINDOWS-1250, null-terminated) | +| `selected_spell_ui_index` | `u16` | UI index of selected spell | -**Note**: The full identity block in the save file is larger than 35 bytes. The `CharacterIdentity::read_from` method skips over many runtime fields (inventory serial counters, waypoint data, movement state, pathfinding scratch, etc.) and only retains the trailing 35 bytes. +**Note**: The full identity block in the save file is larger than 35 bytes. The `CharacterIdentity::read_from` method +skips over many runtime fields (inventory serial counters, waypoint data, movement state, pathfinding scratch, etc.) and +only retains the trailing 35 bytes. ### Inventory Data (`inventory`) Raw inventory data for 5 item categories, each prefixed with a `u16` count: -| Category | Record Size | Description | -|----------|-------------|-------------| -| Event items | 244 bytes | Event-type items | -| Misc items | 264 bytes | Misc-type items | -| Edit items | 272 bytes | Edit-type items | -| Weapon items | 292 bytes | Weapon-type items | -| Heal items | 256 bytes | Heal-type items | - -Each item record contains a copy of its database definition followed by save-runtime fields. -The runtime category is zero-based: `0`=weapon, `1`=heal, `2`=edit, -`3`=misc, and `4`=event. This numbering differs from the one-based item type -used by some map and object files. - -| Category | Runtime fields after/copied into the definition | -|----------|--------------------------------------------------| -| Event | Category, alignment byte, category-local record index | -| Misc | Category, category-local record index, global inventory-instance ID | -| Edit | Category, alignment byte, category-local record index | -| Weapon | Category, global inventory-instance ID | -| Heal | Reserved definition byte, category, category-local record index, two runtime scratch bytes | - -The global inventory-instance ID connects a weapon to equipped slots and connects -weapon or miscellaneous items to inventory placement cells. Heal runtime scratch -bytes are normally zero, but saved games show that they are not initialized consistently. +| Category | Record Size | Description | +|--------------|-------------|-------------------| +| Event items | 244 bytes | Event-type items | +| Misc items | 264 bytes | Misc-type items | +| Edit items | 272 bytes | Edit-type items | +| Weapon items | 292 bytes | Weapon-type items | +| Heal items | 256 bytes | Heal-type items | + +Each item record contains a copy of its database definition followed by save-runtime fields. The runtime category is +zero-based: `0`=weapon, `1`=heal, `2`=edit, +`3`=misc, and `4`=event. This numbering differs from the one-based item type used by some map and object files. + +| Category | Runtime fields after/copied into the definition | +|----------|--------------------------------------------------------------------------------------------| +| Event | Category, alignment byte, category-local record index | +| Misc | Category, category-local record index, global inventory-instance ID | +| Edit | Category, alignment byte, category-local record index | +| Weapon | Category, global inventory-instance ID | +| Heal | Reserved definition byte, category, category-local record index, two runtime scratch bytes | + +The global inventory-instance ID connects a weapon to equipped slots and connects weapon or miscellaneous items to +inventory placement cells. Heal runtime scratch bytes are normally zero, but saved games show that they are not +initialized consistently. ### Inventory Slots (`inventory_slots`) 3,780 bytes (108 + 96 + 3,576) containing: -| Section | Size | Description | -|---------|------|-------------| -| Equipped equipment | 108 bytes (12 × 9) | 12 equipment slots | -| Belt potions | 96 bytes (6 × 16) | 6 belt item cells | +| Section | Size | Description | +|---------------------|------------------------|-------------------------------| +| Equipped equipment | 108 bytes (12 × 9) | 12 equipment slots | +| Belt potions | 96 bytes (6 × 16) | 6 belt item cells | | Inventory placement | 3,576 bytes (189 × 20) | 3 pages × 7 columns × 9 cells | ### Learned Spells (`learned_spells`) @@ -283,122 +289,123 @@ bytes are normally zero, but saved games show that they are not initialized cons ### Party Members (`party_members`) Variable-length records, each consisting of: + - A 21-byte name (WINDOWS-1250, null-terminated) - A 300-byte `PartyMemberBinaryRecord` containing stats, position, AI state, and combat data -- An optional 52-byte combat snapshot (48 bytes + 4-byte terminator), present when the companion has an active combat object +- An optional 52-byte combat snapshot (48 bytes + 4-byte terminator), present when the companion has an active combat + object -The 300-byte payload is a serialized stream of overlapping four-byte runtime -snapshots. Adjacent snapshots frequently begin one or two bytes apart. As a -result, the stream repeats parts of health, mana, class, level, spell IDs, -animation flags, coordinates, and other fields. The parser exposes the primary -values and preserves each repeated snapshot tail or overlap under an explicit +The 300-byte payload is a serialized stream of overlapping four-byte runtime snapshots. Adjacent snapshots frequently +begin one or two bytes apart. As a result, the stream repeats parts of health, mana, class, level, spell IDs, animation +flags, coordinates, and other fields. The parser exposes the primary values and preserves each repeated snapshot tail or +overlap under an explicit `*_snapshot_tail` or `*_snapshot_overlap` name. ### Events (`events`) 2,251 fixed-size records (284 bytes each): -| Field | Type | Description | -|-------|------|-------------| -| `event_id` | `u32` | Event identifier and fixed-table index | -| `required_event_id` | `u32` | Event whose triggered state controls conditional event types | -| `event_type` | `u32` | Dispatch rule: `0`=once unconditionally, `1`=limited unconditionally, `2`=always unconditionally, `3`=once while required event is untriggered, `4`=limited while required event is untriggered, `5`=always while required event is untriggered, `6`=once after required event triggers, `7`=limited after required event triggers, `8`=always after required event triggers | -| `script_filename` | String (260 bytes) | Script filename (WINDOWS-1250, null-terminated) | -| `execution_limit` | `u32` | Trigger limit used by event types `1`, `4`, and `7` | -| `execution_count` | `u32` | Number of times dispatch has started | -| `has_triggered` | `u32` | Triggered state: `0`=not triggered, `1`=triggered | +| Field | Type | Description | +|---------------------|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `event_id` | `u32` | Event identifier and fixed-table index | +| `required_event_id` | `u32` | Event whose triggered state controls conditional event types | +| `event_type` | `u32` | Dispatch rule: `0`=once unconditionally, `1`=limited unconditionally, `2`=always unconditionally, `3`=once while required event is untriggered, `4`=limited while required event is untriggered, `5`=always while required event is untriggered, `6`=once after required event triggers, `7`=limited after required event triggers, `8`=always after required event triggers | +| `script_filename` | String (260 bytes) | Script filename (WINDOWS-1250, null-terminated) | +| `execution_limit` | `u32` | Trigger limit used by event types `1`, `4`, and `7` | +| `execution_count` | `u32` | Number of times dispatch has started | +| `has_triggered` | `u32` | Triggered state: `0`=not triggered, `1`=triggered | ### Post-Events Data (`post_events`) Character movement-event log between the events and journal sections: -| Field | Type | Description | -|-------|------|-------------| -| `shake_active` | `u32` | Screen-shake active flag (offset `+0x244`; zero in shipped saves) | -| `shake_frames_remaining` | `u32` | Screen-shake frames remaining (offset `+0x248`; zero in shipped saves) | -| `walk_milestones` | `Vec` | Walk milestone events (count × 24 bytes; container `+0x208`) | -| `walk_completions` | `Vec` | Walk completion events (count × 24 bytes; container `+0x21c`) | -| `recruitable_companion_world_presence` | `[u32; 8]` | World-presence state for each recruitable companion (`0`=removed from map, `1`=available in world) | -| `dismissed_companion_progression` | `[DismissedCompanionProgression; 8]` | Retained progression for companions that previously left the party | +| Field | Type | Description | +|----------------------------------------|--------------------------------------|----------------------------------------------------------------------------------------------------| +| `shake_active` | `u32` | Screen-shake active flag (offset +580; zero by default) | +| `shake_frames_remaining` | `u32` | Screen-shake frames remaining (offset +584; zero by default) | +| `walk_milestones` | `Vec` | Walk milestone events (count × 24 bytes; container offset +520) | +| `walk_completions` | `Vec` | Walk completion events (count × 24 bytes; container offset +540) | +| `recruitable_companion_world_presence` | `[u32; 8]` | World-presence state for each recruitable companion (`0`=removed from map, `1`=available in world) | +| `dismissed_companion_progression` | `[DismissedCompanionProgression; 8]` | Retained progression for companions that previously left the party | Each dismissed-companion progression record contains three bytes: -| Field | Type | Description | -|-------|------|-------------| -| `is_saved` | `u8` | Whether retained progression exists (`0`=none, `1`=present) | -| `companion_level` | `u8` | Companion level when the progression was retained | -| `player_level` | `u8` | Player level when the progression was retained | +| Field | Type | Description | +|-------------------|------|-------------------------------------------------------------| +| `is_saved` | `u8` | Whether retained progression exists (`0`=none, `1`=present) | +| `companion_level` | `u8` | Companion level when the progression was retained | +| `player_level` | `u8` | Player level when the progression was retained | -Milestone records are written at walk animation milestones (walk progress -reaching `path_length - 2`, or the walk frame counter reaching 4). Completion -records are written when a walk cycle finishes. +Milestone records are written at walk animation milestones (walk progress reaching `path_length - 2`, or the walk frame +counter reaching 4). Completion records are written when a walk cycle finishes. #### Walk Milestone Record (24 bytes) -| Field | Type | Description | -|-------|------|-------------| -| `id` | `u32` | Event id: `400` in the 1.45 binary; `10`/`100`/`200`/`300` while the walk-freshness counter is active; ascending global counter in shipped saves | -| `direction` | `u32` | Walk direction (animation-step direction, 0-7) | -| `state` | `u32` | Character state byte (`1` while walking) | -| `walk_type` | `u32` | Walk-type flag: `0` in the 1.45 binary (which duplicates `direction` here); `1` in shipped saves | -| `x` | `u32` | X coordinate | -| `y` | `u32` | Y coordinate | +| Field | Type | Description | +|-------------|-------|-------------------------------------------------------------------------------------------------------------------------| +| `id` | `u32` | Event id: `400`; `10`/`100`/`200`/`300` while the walk-freshness counter is active; ascending global counter by default | +| `direction` | `u32` | Walk direction (animation-step direction, 0-7) | +| `state` | `u32` | Character state byte (`1` while walking) | +| `walk_type` | `u32` | Walk-type flag: `0` in save-format version 1.45 (which duplicates `direction` here); `1` by default | +| `x` | `u32` | X coordinate | +| `y` | `u32` | Y coordinate | #### Walk Completion Record (24 bytes) -| Field | Type | Description | -|-------|------|-------------| -| `id` | `u32` | Event id: `2000` in the 1.45 binary; ascending global counter in shipped saves | -| `direction` | `u32` | Normalized walk direction (0-3; walk directions 4-7 map to 0-3) | -| `diagonal` | `u32` | Diagonal flag (`1` for diagonal walk directions) | -| `character_index` | `u32` | Character index (`0` for party members in the 1.45 binary; `0`-`2` in shipped saves) | -| `x` | `u32` | X coordinate | -| `y` | `u32` | Y coordinate | +| Field | Type | Description | +|-------------------|-------|-----------------------------------------------------------------------------------------| +| `id` | `u32` | Event id: `2000` in save-format version 1.45; ascending global counter by default | +| `direction` | `u32` | Normalized walk direction (0-3; walk directions 4-7 map to 0-3) | +| `diagonal` | `u32` | Diagonal flag (`1` for diagonal walk directions) | +| `character_index` | `u32` | Character index (`0` for party members in save-format version 1.45; `0`-`2` by default) | +| `x` | `u32` | X coordinate | +| `y` | `u32` | Y coordinate | ### Journal Data (`journal`) -| Field | Type | Description | -|-------|------|-------------| -| `header` | `JournalHeader` | 42-byte journal UI state | -| `main` | `Vec` | Main quest entries (100 × 37 bytes) | -| `side` | `Vec` | Side quest entries (100 × 37 bytes) | -| `trade` | `Vec` | Trading offer entries (100 × 37 bytes) | +| Field | Type | Description | +|----------|---------------------|----------------------------------------| +| `header` | `JournalHeader` | 42-byte journal UI state | +| `main` | `Vec` | Main quest entries (100 × 37 bytes) | +| `side` | `Vec` | Side quest entries (100 × 37 bytes) | +| `trade` | `Vec` | Trading offer entries (100 × 37 bytes) | #### Journal Header (42 bytes) -| Field | Type | Description | -|-------|------|-------------| -| `is_world_map_open` | `u8` | Combined-interface view: `0`=journal, `1`=world map | -| `selected_map_layer` | `u8` | Selected world-map layer (`0`-`2`) | -| `map_marker_discovery` | `WorldMapMarkerDiscovery` | Persistent marker discovery state for all three world-map layers | -| `active_section` | `u8` | Active journal section: `0`=main, `1`=side, `2`=trade | -| `section_first_visible_entries` | `[u8; 3]` | First visible entry index per section | -| `section_selected_entries` | `[u8; 3]` | Selected entry index per section | -| `section_entry_counts` | `[u8; 3]` | Active entry count per section | +| Field | Type | Description | +|---------------------------------|---------------------------|------------------------------------------------------------------| +| `is_world_map_open` | `u8` | Combined-interface view: `0`=journal, `1`=world map | +| `selected_map_layer` | `u8` | Selected world-map layer (`0`-`2`) | +| `map_marker_discovery` | `WorldMapMarkerDiscovery` | Persistent marker discovery state for all three world-map layers | +| `active_section` | `u8` | Active journal section: `0`=main, `1`=side, `2`=trade | +| `section_first_visible_entries` | `[u8; 3]` | First visible entry index per section | +| `section_selected_entries` | `[u8; 3]` | Selected entry index per section | +| `section_entry_counts` | `[u8; 3]` | Active entry count per section | `WorldMapMarkerDiscovery` divides the 30-byte marker storage as follows: -| Field | Type | Description | -|-------|------|-------------| -| `layer_0` | `[u8; 10]` | Layer 0 marker flags: `0`=hidden, `1`=discovered | -| `layer_1` | `[u8; 10]` | Layer 1 marker flags: `0`=hidden, `1`=discovered | -| `layer_2` | `[u8; 7]` | Layer 2 marker flags: `0`=hidden, `1`=discovered | -| `unused_layer_2_slots` | `[u8; 3]` | Unused tail of layer 2's ten-slot storage | +| Field | Type | Description | +|------------------------|------------|--------------------------------------------------| +| `layer_0` | `[u8; 10]` | Layer 0 marker flags: `0`=hidden, `1`=discovered | +| `layer_1` | `[u8; 10]` | Layer 1 marker flags: `0`=hidden, `1`=discovered | +| `layer_2` | `[u8; 7]` | Layer 2 marker flags: `0`=hidden, `1`=discovered | +| `unused_layer_2_slots` | `[u8; 3]` | Unused tail of layer 2's ten-slot storage | #### Journal Entry (37 bytes) -| Field | Type | Description | -|-------|------|-------------| -| `entry_index` | `u8` | Slot within this journal section | -| `quest_title` | String (32 bytes) | Quest title (WINDOWS-1250, null-terminated) | -| `quest_id` | `u8` | Quest ID from Quest.scr | -| `follow_up_quest_id_1` | `u8` | First linked follow-up quest ID, or `0` if absent | -| `follow_up_quest_id_2` | `u8` | Second linked follow-up quest ID, or `0` if absent | -| `is_completed` | `u8` | Completion flag: `0`=active, `1`=completed | +| Field | Type | Description | +|------------------------|-------------------|----------------------------------------------------| +| `entry_index` | `u8` | Slot within this journal section | +| `quest_title` | String (32 bytes) | Quest title (WINDOWS-1250, null-terminated) | +| `quest_id` | `u8` | Quest ID from Quest.scr | +| `follow_up_quest_id_1` | `u8` | First linked follow-up quest ID, or `0` if absent | +| `follow_up_quest_id_2` | `u8` | Second linked follow-up quest ID, or `0` if absent | +| `is_completed` | `u8` | Completion flag: `0`=active, `1`=completed | ## Binary Record Macro -Several structs in this module use the `#[derive(BinaryRecord)]` macro from `dispel-macros`, which generates `parse` and `write` methods for binary serialization. The `#[binary_record(...)]` attribute controls field encoding, including: +Several structs in this module use the `#[derive(BinaryRecord)]` macro from `dispel-macros`, which generates `parse` and +`write` methods for binary serialization. The `#[binary_record(...)]` attribute controls field encoding, including: - `string(encoding = "WINDOWS-1250", size = N)`: Fixed-size null-terminated string - `size = N`: Fixed-size byte array @@ -409,13 +416,16 @@ Several structs in this module use the `#[derive(BinaryRecord)]` macro from `dis The writer performs preflight validation before serializing: 1. **Count limits**: All collection counts must fit in their wire types (`u16` or `u32`) -2. **Fixed collection lengths**: Sprite paths (4), learned spells (41), events (2,251), journal entries (100 per section), map viewport cells (500) -3. **Cross-field consistency**: Visited-map count must match map section count and map ID count; party member count must match the stored count +2. **Fixed collection lengths**: Sprite paths (4), learned spells (41), events (2,251), journal entries (100 per + section), map viewport cells (500) +3. **Cross-field consistency**: Visited-map count must match map section count and map ID count; party member count must + match the stored count 4. **Trailer size validation**: Map extra-object trailer size must match the computed expected size ## Extractor -The save file format is parsed by the `SaveFile` struct in `src/references/save_file/mod.rs`, which implements the `Extractor` trait. +The save file format is parsed by the `SaveFile` struct in `src/references/save_file/mod.rs`, which implements the +`Extractor` trait. ### How to Run @@ -430,6 +440,7 @@ cargo run -- database import "fixtures/Dispel/" "database.sqlite" ## Purpose This file stores the complete player game state, including: + 1. Character progression (stats, level, XP, gold) 2. Inventory and equipment 3. Party member recruitment and state @@ -440,13 +451,17 @@ This file stores the complete player game state, including: ## Legal Notice -⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any copyrighted game content, save data, or proprietary assets. All references to save file structures are for **educational and research purposes** to document file organization and data structures. +⚠️ **DISCLAIMER**: This documentation describes technical file format specifications only. It does not distribute any +copyrighted game content, save data, or proprietary assets. All references to save file structures are for **educational +and research purposes** to document file organization and data structures. -**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the trademark owner. +**DISPEL®** is a registered trademark. This documentation is **not affiliated with, endorsed by, or sponsored by** the +trademark owner. ### Legal Compliance This documentation: + - Describes **file format specifications only** - Does **not** distribute any save data or game content - Focuses on **technical organization**, not creative content @@ -457,8 +472,10 @@ This documentation: - All integers are little-endian - Text fields use WINDOWS-1250 encoding -- The `game_tmp_blob_size` field in the header acts as a jump address; the reader seeks to this offset after parsing map sections -- The `CharacterIdentity` struct only retains the trailing 35 bytes of the full identity block, skipping many runtime fields +- The `game_tmp_blob_size` field in the header acts as a jump address; the reader seeks to this offset after parsing map + sections +- The `CharacterIdentity` struct only retains the trailing 35 bytes of the full identity block, skipping many runtime + fields - Party member records have an optional combat snapshot that is conditionally present based on a marker value - Reserved runtime and header fields are preserved verbatim for round-trip fidelity. - The writer validates all fixed-size constraints before serialization to prevent corrupt output diff --git a/docs/files/Sprites.spr.md b/docs/files/Sprites.spr.md index 5d49dbea..b8328d7e 100644 --- a/docs/files/Sprites.spr.md +++ b/docs/files/Sprites.spr.md @@ -1,8 +1,11 @@ -# *.spr - Sprite File Format +# *.spr — Sprite File Format + +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. ## File Information -- **Location**: `CharacterInGame/*.spr`, `NpcInGame/*.spr`, `MonsterInGame/*.spr`, `ExtraInGame/*.spr`, basically: `**/.spr` +- **Location**: `CharacterInGame/*.spr`, `NpcInGame/*.spr`, `MonsterInGame/*.spr`, `ExtraInGame/*.spr` - **Format**: Binary (Little-Endian) - **Color Format**: RGB565 (2 bytes per pixel) - **File Extension**: `.spr` @@ -14,8 +17,8 @@ ``` Offset Size Description ---------- ------------ ------------------------------------------- -0x0000 268 bytes Unknown file header (skip to offset 268) -0x010C variable Sequence 1 +0 268 bytes Unknown file header (skip to offset 268) +268 variable Sequence 1 ... variable Sequence 2 ... variable Sequence N ``` @@ -26,32 +29,33 @@ The file ends with the last byte of the last sequence's pixel data. There is no ## Sequence Detection -Sequences are not stored at fixed offsets. They must be found by scanning the file for a valid sequence header pattern. The algorithm reads 15 consecutive i32 values (60 bytes) and checks two valid patterns: +Sequences are not stored at fixed offsets. They must be found by scanning the file for a valid sequence header pattern. +The algorithm reads 15 consecutive i32 values (60 bytes) and checks two valid patterns: **Pattern A (no leading stamp):** -| Field | Constraint | Meaning | -|-------|------------|---------| -| `ints[0]` | `== 0` | No stamp | -| `ints[1]` | `> 0 && < 255` | Frame count (1-254 frames) | -| `ints[2]` | `== 0` | Padding | -| `ints[11]` | `> 0` | First frame width | -| `ints[12]` | `> 0` | First frame height | -| `ints[11] * ints[12]` | `== ints[13]` | Width × height == pixel count | +| Field | Constraint | Meaning | +|-----------------------|----------------|-------------------------------| +| `ints[0]` | `== 0` | No stamp | +| `ints[1]` | `> 0 && < 255` | Frame count (1–254 frames) | +| `ints[2]` | `== 0` | Padding | +| `ints[11]` | `> 0` | First frame width | +| `ints[12]` | `> 0` | First frame height | +| `ints[11] * ints[12]` | `== ints[13]` | Width × height == pixel count | **Pattern B (with leading stamp 8, 0):** -| Field | Constraint | Meaning | -|-------|------------|---------| -| `ints[0]` | `== 8` | Stamp byte | -| `ints[1]` | `== 0` | Stamp padding | -| `ints[2]` | `> 0 && < 255` | Frame count (1-254 frames) | -| `ints[3]` | `== 0` | Padding | -| `ints[12]` | `> 0` | First frame width | -| `ints[13]` | `> 0` | First frame height | -| `ints[12] * ints[13]` | `== ints[14]` | Width × height == pixel count | +| Field | Constraint | Meaning | +|-----------------------|----------------|-------------------------------| +| `ints[0]` | `== 8` | Stamp byte | +| `ints[1]` | `== 0` | Stamp padding | +| `ints[2]` | `> 0 && < 255` | Frame count (1–254 frames) | +| `ints[3]` | `== 0` | Padding | +| `ints[12]` | `> 0` | First frame width | +| `ints[13]` | `> 0` | First frame height | +| `ints[12] * ints[13]` | `== ints[14]` | Width × height == pixel count | -If neither pattern matches, the scanner advances by 4 bytes (one i32) and retries. A single skip is treated as zero skips (quirk in the original parser). +If neither pattern matches, the scanner advances by 4 bytes (one i32) and retries. --- @@ -59,12 +63,12 @@ If neither pattern matches, the scanner advances by 4 bytes (one i32) and retrie Once a valid pattern is found, the sequence header is read: -| Offset (relative) | Size | Description | -|-------------------|------|-------------| -| `0x00` | `i32` | Stamp: `8` (Pattern B) or `0` (Pattern A) | -| `0x04` | `i32` | If stamp was `8`: always `0`. If stamp was `0`: frame count | -| `0x08` | `i32` | Frame count (Pattern A only) | -| `0x0C` | `i32` | Always `0` (padding) | +| Offset (relative) | Size | Description | +|-------------------|-------|-------------------------------------------------------------| +| `0` | `i32` | Stamp: `8` (Pattern B) or `0` (Pattern A) | +| `4` | `i32` | If stamp was `8`: always `0`. If stamp was `0`: frame count | +| `8` | `i32` | Frame count (Pattern A only) | +| `12` | `i32` | Always `0` (padding) | After the header, frame metadata begins immediately. @@ -74,14 +78,14 @@ After the header, frame metadata begins immediately. Each frame in the sequence has a metadata block: -| Offset | Size | Description | -|--------|------|-------------| -| `0x00` | `24` | Unknown data (6 × i32, skipped during parsing) | -| `0x18` | `i32` | `origin_x` — X offset from anchor point | -| `0x1C` | `i32` | `origin_y` — Y offset from anchor point | -| `0x20` | `i32` | `width` — frame width in pixels | -| `0x24` | `i32` | `height` — frame height in pixels | -| `0x28` | `u32` | `pixel_count` — number of RGB565 pixels (NOT bytes) | +| Offset | Size | Description | +|--------|-------|-----------------------------------------------------| +| `0` | `24` | Unknown data (6 × i32, skipped during parsing) | +| `24` | `i32` | `origin_x` — X offset from anchor point | +| `28` | `i32` | `origin_y` — Y offset from anchor point | +| `32` | `i32` | `width` — frame width in pixels | +| `36` | `i32` | `height` — frame height in pixels | +| `40` | `u32` | `pixel_count` — number of RGB565 pixels (NOT bytes) | The pixel data size in bytes is: `pixel_count * 2`. @@ -93,21 +97,21 @@ After the metadata block, pixel data follows immediately. Each pixel is stored as RGB565 (16-bit, Little-Endian): -| Bits | Channel | Range | -|------|---------|-------| -| 15-11 | Red | 5 bits (0-31) | -| 10-5 | Green | 6 bits (0-63) | -| 4-0 | Blue | 5 bits (0-31) | +| Bits | Channel | Range | +|-------|---------|---------------| +| 15–11 | Red | 5 bits (0–31) | +| 10–5 | Green | 6 bits (0–63) | +| 4–0 | Blue | 5 bits (0–31) | **Conversion to 8-bit RGB:** ``` -R = (red_value << 3) // 5→8 bits, range 0-248 -G = (green_value << 2) // 6→8 bits, range 0-252 -B = (blue_value << 3) // 5→8 bits, range 0-248 +R = (red_value << 3) // 5→8 bits, range 0–248 +G = (green_value << 2) // 6→8 bits, range 0–252 +B = (blue_value << 3) // 5→8 bits, range 0–248 ``` -Pixel value `0x0000` is transparent. +Pixel value `0` (all channels zero) is transparent. Pixels are stored in row-major order (left-to-right, top-to-bottom). @@ -115,7 +119,8 @@ Pixels are stored in row-major order (left-to-right, top-to-bottom). ## Bounding Rectangle Calculation -Frames within a sequence may have different sizes and origins. To render them into a common canvas, a bounding rectangle is computed: +Frames within a sequence may have different sizes and origins. To render them into a common canvas, a bounding rectangle +is computed: ``` max_left = max(origin_x) @@ -139,11 +144,13 @@ y = max_up - origin_y ## Reading a File (Step-by-Step) 1. Open the file, seek to offset 268. -2. Call `seek_next_sequence()` to find the next valid sequence header. If it returns `false`, you've reached the end of the file. -3. Call `get_sequence_info()` to parse the header and all frame metadata. This advances the reader past all pixel data to the end of the sequence. +2. Call `seek_next_sequence()` to find the next valid sequence header. If it returns `false`, you've reached the end of + the file. +3. Call `get_sequence_info()` to parse the header and all frame metadata. This advances the reader past all pixel data + to the end of the sequence. 4. To render frames, seek back to `sequence_start_position` before reading pixel data. 5. Seek to `sequence_end_position` to continue scanning for the next sequence. -6. Repeat steps 2-5 until `seek_next_sequence()` returns `false`. +6. Repeat steps 2–5 until `seek_next_sequence()` returns `false`. --- @@ -188,19 +195,19 @@ Each frame has an origin point (`origin_x`, `origin_y`) that defines: ## Extractor -An extractor is available in `src/sprite.rs` to parse this file format. +The file is parsed by the `src/sprite.rs` module. ### CLI Commands ```bash # Extract individual frames as separate PNG files -cargo run -- sprite "fixtures/Dispel/CharacterInGame/M_BODY1.spr" +cargo run -- sprite "path/to/file.spr" # Extract animation sequences as horizontal strip PNGs -cargo run -- sprite "fixtures/Dispel/CharacterInGame/M_BODY1.spr" --mode animation +cargo run -- sprite "path/to/file.spr" --mode animation # Output sprite metadata as JSON (no rendering) -cargo run -- sprite "fixtures/Dispel/ExtraInGame/Quest.spr" --info +cargo run -- sprite "path/to/file.spr" --info ``` ### Library API @@ -213,15 +220,13 @@ use dispel_core::sprite::{ }; // Get all sequences and frame counts -let frame_counts = get_sprite_metadata(Path::new("Atack.spr"))?; -// => [4, 4, 10] +let frame_counts = get_sprite_metadata(Path::new("file.spr"))?; // Get full metadata -let info = get_sprite_info(Path::new("Atack.spr"))?; -// => SpriteInfoJson { sequence_count: 3, total_frames: 18, ... } +let info = get_sprite_info(Path::new("file.spr"))?; // Render sequence 0 to PNG buffers -let pngs = get_sequence_pngs_by_index(Path::new("Atack.spr"), 0)?; +let pngs = get_sequence_pngs_by_index(Path::new("file.spr"), 0)?; // => Vec> — one PNG buffer per frame ``` diff --git a/docs/files/Wave.ini.md b/docs/files/Wave.ini.md index 33cdd86a..0db73cad 100644 --- a/docs/files/Wave.ini.md +++ b/docs/files/Wave.ini.md @@ -1,13 +1,17 @@ # Wave.ini +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + ## Purpose `Wave.ini` maps a sound identifier to an SNF filename and a simultaneous-playback limit. ## File Structure -- Encoding: EUC-KR. -- Format: comma-separated text. +- **Location**: `Wave.ini` +- **Encoding**: EUC-KR +- **Format**: CSV (Comma-Separated Values) - Comment prefix: `;`. - Each non-comment row has three fields. @@ -15,11 +19,11 @@ ,, ``` -| Field | Type | Description | -|---|---|---| -| `id` | `i32` | Sound identifier. | -| `snf_filename` | string or `null` | Referenced SNF filename. | -| `max_simultaneous_plays` | `i32` | Maximum concurrent instances of this sound. | +| Field | Type | Description | +|--------------------------|------------------|---------------------------------------------| +| `id` | `i32` | Sound identifier. | +| `snf_filename` | string or `null` | Referenced SNF filename. | +| `max_simultaneous_plays` | `i32` | Maximum concurrent instances of this sound. | ## Runtime Behavior @@ -31,10 +35,8 @@ This value is not a priority, channel, or loop flag. ## Parser -The Rust parser is [wave_ini.rs](../../src/references/wave_ini.rs). +The Rust parser is in `src/references/wave_ini.rs` (`WaveIni` struct, `Extractor` trait). ## Legal Notice This document describes a file format. It contains no game records, asset names, or other game-content data. - -**DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark owner. diff --git a/src/references/save_file/events.rs b/src/references/save_file/events.rs index a9abce3e..9672150c 100644 --- a/src/references/save_file/events.rs +++ b/src/references/save_file/events.rs @@ -158,7 +158,7 @@ impl PostEventsData { /// reaches its movement milestone. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, BinaryRecord)] pub struct WalkMilestoneRecord { - /// Event id. `400` in the 1.45 binary; `10`/`100`/`200`/`300` when the + /// Event id. `400`; `10`/`100`/`200`/`300` when the /// walk-freshness counter is active; ascending global counter in shipped /// saves (53, 66, 73, ...). pub id: u32, @@ -166,7 +166,7 @@ pub struct WalkMilestoneRecord { pub direction: u32, /// Character movement state (`0`=idle, `1`=walking). pub state: u32, - /// Walk-type flag. `0` in the 1.45 binary (which duplicates `direction` + /// Walk-type flag. `0` (which duplicates `direction` /// into this slot); `1` in shipped saves. pub walk_type: u32, /// X coordinate. @@ -180,14 +180,14 @@ pub struct WalkMilestoneRecord { /// The record is created when the active path reaches its completion point. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, BinaryRecord)] pub struct WalkCompletionRecord { - /// Event id. `2000` in the 1.45 binary; ascending global counter in + /// Event id. `2000`; ascending global counter in /// shipped saves (684, 775, 828, ...). pub id: u32, /// Normalized walk direction (0-3; walk directions 4-7 map to 0-3). pub direction: u32, /// Diagonal flag: `1` when the walk direction is diagonal. pub diagonal: u32, - /// Character index (`0` for party members in the 1.45 binary; `0`-`2` in + /// Character index (`0` for party members; `0`-`2` in /// shipped saves). pub character_index: u32, /// X coordinate. From 3abd60c44042da5b851976cee06a8865fc600aba Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:13:11 +0200 Subject: [PATCH 06/49] Add Save.ifo file parser --- src/references/mod.rs | 1 + src/references/save_ifo.rs | 164 +++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/references/save_ifo.rs diff --git a/src/references/mod.rs b/src/references/mod.rs index 8db3697b..07debd98 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -93,6 +93,7 @@ pub mod party_level_db; pub mod party_ref; pub mod quest_scr; pub mod save_file; +pub mod save_ifo; pub mod store_db; pub mod wave_ini; pub mod weapons_db; diff --git a/src/references/save_ifo.rs b/src/references/save_ifo.rs new file mode 100644 index 00000000..0237c61b --- /dev/null +++ b/src/references/save_ifo.rs @@ -0,0 +1,164 @@ +// Save-slot metadata index extraction and serialization for Dispel RPG. + +use crate::references::extractor::Extractor; +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +/// Total size of a `Save.ifo` file in bytes (6 × 32-byte slots + 32-byte tail). +pub const SAVE_IFO_SIZE: usize = 224; +/// Number of save slots described by the file. +pub const SLOT_COUNT: usize = 6; + +/// Metadata index for the six save slots (`0.sav` … `5.sav`). +/// +/// Reads file: `Save.ifo` (game root directory) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SaveIfo { + /// Per-slot metadata; always exactly [`SLOT_COUNT`] entries. + pub slots: Vec, + /// Unknown float; the game writes the constant 1.4 before every save. + pub unknown_float: f32, + /// Key of this session's payload inside the `game.tmp` append-log. + pub game_tmp_key: u32, + /// Map/world id that was active when the game was last saved. + pub map_id: u32, + /// Unknown; observed zero. Reset by the game when starting a new game. + pub unknown: u32, + /// Element counts snapshotted for traversing `game.tmp` payloads. + pub payload_counts: [u32; 4], +} + +/// Metadata for a single save slot. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SaveSlotInfo { + /// Never written by the game; zero in all known samples. + pub reserved: [u8; 12], + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + /// Byte 0: slot occupied flag; bytes 1..3: padding (preserved verbatim). + pub flags: [u8; 4], +} + +impl SaveSlotInfo { + pub fn is_occupied(&self) -> bool { + self.flags[0] != 0 + } +} + +impl SaveIfo { + /// Parse a `Save.ifo` from binary data. + pub fn parse(data: &[u8]) -> std::io::Result { + if data.len() != SAVE_IFO_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Save.ifo must be exactly {} bytes, got {} bytes", + SAVE_IFO_SIZE, + data.len() + ), + )); + } + + let mut cursor = std::io::Cursor::new(data); + + let mut slots = Vec::with_capacity(SLOT_COUNT); + for _ in 0..SLOT_COUNT { + let mut reserved = [0u8; 12]; + cursor.read_exact(&mut reserved)?; + let month = cursor.read_u32::()?; + let day = cursor.read_u32::()?; + let hour = cursor.read_u32::()?; + let minute = cursor.read_u32::()?; + let mut flags = [0u8; 4]; + cursor.read_exact(&mut flags)?; + slots.push(SaveSlotInfo { + reserved, + month, + day, + hour, + minute, + flags, + }); + } + + let unknown_float = cursor.read_f32::()?; + let game_tmp_key = cursor.read_u32::()?; + let map_id = cursor.read_u32::()?; + let unknown = cursor.read_u32::()?; + let mut payload_counts = [0u32; 4]; + for count in payload_counts.iter_mut() { + *count = cursor.read_u32::()?; + } + + if slots.len() != SLOT_COUNT { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Save.ifo must contain exactly {} slot records, got {}", + SLOT_COUNT, + slots.len() + ), + )); + } + + Ok(SaveIfo { + slots, + unknown_float, + game_tmp_key, + map_id, + unknown, + payload_counts, + }) + } + + /// Serialize to its binary representation. + pub fn write_to(&self, writer: &mut W) -> std::io::Result<()> { + if self.slots.len() != SLOT_COUNT { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Save.ifo must contain exactly {} slot records, got {}", + SLOT_COUNT, + self.slots.len() + ), + )); + } + for slot in &self.slots { + writer.write_all(&slot.reserved)?; + writer.write_u32::(slot.month)?; + writer.write_u32::(slot.day)?; + writer.write_u32::(slot.hour)?; + writer.write_u32::(slot.minute)?; + writer.write_all(&slot.flags)?; + } + writer.write_f32::(self.unknown_float)?; + writer.write_u32::(self.game_tmp_key)?; + writer.write_u32::(self.map_id)?; + writer.write_u32::(self.unknown)?; + for count in &self.payload_counts { + writer.write_u32::(*count)?; + } + Ok(()) + } +} + +impl Extractor for SaveIfo { + fn parse(reader: &mut R, _len: u64) -> std::io::Result> { + let mut data = Vec::new(); + reader.read_to_end(&mut data)?; + Ok(vec![SaveIfo::parse(&data)?]) + } + + fn to_writer(records: &[Self], writer: &mut W) -> std::io::Result<()> { + if records.len() != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "SaveIfo can only serialize one record at a time", + )); + } + records[0].write_to(writer) + } +} From ccd694969a41851127445cdfb21c2aac8a2a3881 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:15:52 +0200 Subject: [PATCH 07/49] Register the Save.ifo file parser and --- .../src/components/map_render/view_state.rs | 9 +- .../src/editors/map_editor/canvas/hit_test.rs | 29 ++ .../src/editors/map_editor/canvas/mod.rs | 2 +- .../src/editors/map_editor/canvas/tests.rs | 15 +- dispel-gui/src/editors/map_editor/message.rs | 41 +++ .../src/editors/map_editor/update/entity.rs | 3 +- .../src/editors/map_editor/update/map.rs | 262 ++++++++++++++++++ .../src/editors/map_editor/update/mod.rs | 135 ++++----- .../src/editors/map_editor/view/inspector.rs | 9 +- dispel-gui/src/editors/map_editor/view/mod.rs | 246 ++++++++++++---- 10 files changed, 614 insertions(+), 137 deletions(-) diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs index 44d2938f..13c0c0cc 100644 --- a/dispel-gui/src/components/map_render/view_state.rs +++ b/dispel-gui/src/components/map_render/view_state.rs @@ -1,4 +1,4 @@ -use crate::editors::map_editor::message::{MapViewMode, SelectedEntity}; +use crate::editors::map_editor::message::{MapTool, MapViewMode, ObjectBrushMode, SelectedEntity}; use iced::widget::canvas; /// Viewport, layer-visibility, and cursor state for the map canvas. @@ -34,6 +34,11 @@ pub struct MapViewState { pub last_canvas_h: f32, /// Which top-level view is shown (map canvas or sprite browser). pub view_mode: MapViewMode, + /// Active editing tool. Editing a layer requires selecting its tool; + /// layer visibility alone no longer enables editing. + pub active_tool: MapTool, + /// How the object-id brush is applied when the ObjectId tool clicks a tile. + pub object_brush_mode: ObjectBrushMode, /// Selected sprite sequence index in the Sprites browser. pub selected_sprite_sequence: Option, /// Currently selected entity in the inspector panel. @@ -76,6 +81,8 @@ impl Default for MapViewState { last_canvas_w: 1200.0, last_canvas_h: 800.0, view_mode: MapViewMode::Map, + active_tool: MapTool::Pan, + object_brush_mode: ObjectBrushMode::Paint, selected_sprite_sequence: None, selected_entity: None, tile_layer_cache: canvas::Cache::new(), diff --git a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs index dd2248e4..8279ea1f 100644 --- a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs +++ b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs @@ -127,7 +127,36 @@ fn find_hovered_object_id_tile(state: &MapEditorState, cx: f32, cy: f32) -> Opti Some((tile_x, tile_y)) } +/// Entity-only hit test for the Pan tool (selection never mutates). +pub fn find_entity_at(state: &MapEditorState, cx: f32, cy: f32) -> Option { + find_hovered_entity_impl(state, cx, cy) +} + +/// Resolve the raw tile under the cursor via the standard screen_to_tile path, +/// regardless of layer visibility or entities under the cursor. Used by the +/// editing tools (Collision / ObjectId / EventInspect). +pub fn find_tile_at(state: &MapEditorState, cx: f32, cy: f32) -> Option<(i32, i32)> { + let map_handle = state.map_data()?; + let model = &map_handle.0.model; + let diagonal = model.tiled_map_width + model.tiled_map_height; + + screen_to_tile( + cx, + cy, + diagonal, + state.view.pan_x, + state.view.pan_y, + state.view.zoom, + model.tiled_map_width, + model.tiled_map_height, + ) +} + /// Find what's under the cursor, with priority: entity > object-id tile > collision tile > event tile. +/// +/// Legacy priority chain, kept for the Pan tool's hover/status reporting and +/// existing tests. Editing tools should use `find_tile_at` instead so that +/// visibility of a layer doesn't gate editability. pub fn find_hovered_element(state: &MapEditorState, cx: f32, cy: f32) -> Option { // 1. Try entities (existing logic) if let Some(entity) = find_hovered_entity_impl(state, cx, cy) { diff --git a/dispel-gui/src/editors/map_editor/canvas/mod.rs b/dispel-gui/src/editors/map_editor/canvas/mod.rs index d4308c50..8cd5c617 100644 --- a/dispel-gui/src/editors/map_editor/canvas/mod.rs +++ b/dispel-gui/src/editors/map_editor/canvas/mod.rs @@ -22,7 +22,7 @@ pub use crate::components::map_render::{ // ── Re-exports for external consumers ───────────────────────────────────────── -pub use hit_test::find_hovered_element; +pub use hit_test::{find_entity_at, find_hovered_element, find_tile_at}; pub use render_overlays::MapCanvasOverlaysLayer; /// Tile layer type alias — delegates to the shared generic implementation. diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index 5f1391e3..6d98fbae 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -838,7 +838,10 @@ fn test_hovered_element_object_id_tile_when_visible() { state.view.show_object_ids = true; // Insert object_id into the loaded MapData. if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { - Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 42); + Arc::get_mut(&mut handle.0) + .unwrap() + .object_ids + .insert((10, 10), 42); } let (cx, cy) = tile_centre_canvas(&state, 10, 10); @@ -862,7 +865,10 @@ fn test_hovered_element_object_id_tile_not_when_hidden() { ); state.view.show_object_ids = false; if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { - Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 42); + Arc::get_mut(&mut handle.0) + .unwrap() + .object_ids + .insert((10, 10), 42); } let (cx, cy) = tile_centre_canvas(&state, 10, 10); @@ -887,7 +893,10 @@ fn test_hovered_element_object_id_tile_has_priority_over_collision() { state.view.show_object_ids = true; state.view.show_collisions = true; if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { - Arc::get_mut(&mut handle.0).unwrap().object_ids.insert((10, 10), 7); + Arc::get_mut(&mut handle.0) + .unwrap() + .object_ids + .insert((10, 10), 7); } let (cx, cy) = tile_centre_canvas(&state, 10, 10); diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 3a10c950..1f490231 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -208,6 +208,10 @@ pub enum MapEditorMessage { TmxExportComplete(usize, Result), /// Set the object-id brush value for painting (1–511). SetObjectBrush(usize, i32), + /// Select the active canvas tool (Pan / Collision / ObjectId / EventInspect). + SelectTool(usize, MapTool), + /// Set how the object-id brush is applied (Paint / Erase). + SetObjectBrushMode(usize, ObjectBrushMode), } /// Which entity or tile is currently selected in the map editor inspector. @@ -226,6 +230,16 @@ pub enum SelectedEntity { ObjectIdTile(i32, i32), } +/// How the object-id brush is applied on click (enum over boolean per conventions). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ObjectBrushMode { + /// Write the brush value, overwriting whatever was there. + #[default] + Paint, + /// Remove the object-id entry regardless of its current value. + Erase, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MapLayer { Ground, @@ -242,6 +256,33 @@ pub enum MapLayer { ObjectIds, } +/// Active canvas tool. Visibility of a layer no longer implies editability: +/// editing tools must be explicitly selected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MapTool { + /// Select entities / pan the viewport (default). + #[default] + Pan, + /// Click a tile to toggle its collision flag. + Collision, + /// Click a tile to paint/erase an object ID with the current brush. + ObjectId, + /// Click a tile to inspect its event in the inspector (read-only). + EventInspect, +} + +impl MapTool { + /// The map layer this tool edits, if any. + pub fn owning_layer(self) -> Option { + match self { + MapTool::Pan => None, + MapTool::Collision => Some(MapLayer::Collisions), + MapTool::ObjectId => Some(MapLayer::ObjectIds), + MapTool::EventInspect => Some(MapLayer::Events), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum MapViewMode { #[default] diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 8e70cb07..096006cb 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -513,8 +513,7 @@ mod tests { internal_sprites: vec![], sprite_blocks: vec![], }; - state.data.loading_state = - LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); + state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); app.state .workspace diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index cb06f5dd..c0a5f8e4 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -15,6 +15,131 @@ use std::sync::Arc; // ── Message handlers ───────────────────────────────────────────────────────── +use crate::components::map_render::view_state::MapViewState; +use crate::editors::map_editor::message::{MapLayer, MapTool, ObjectBrushMode, SelectedEntity}; +use crate::editors::map_editor::state::MapEditAction; + +/// Whether a display layer is currently visible. +pub fn layer_visible(view: &MapViewState, layer: MapLayer) -> bool { + match layer { + MapLayer::Ground => view.show_ground, + MapLayer::Buildings => view.show_buildings, + MapLayer::Roofs => view.show_roofs, + MapLayer::InternalSprites => view.show_internal_sprites, + MapLayer::Collisions => view.show_collisions, + MapLayer::Events => view.show_events, + MapLayer::Monsters => view.show_monsters, + MapLayer::Npcs => view.show_npcs, + MapLayer::NpcWaypoints => view.show_npc_waypoints, + MapLayer::Objects => view.show_objects, + MapLayer::DrawItems => view.show_draw_items, + MapLayer::ObjectIds => view.show_object_ids, + } +} + +fn set_layer_visible(view: &mut MapViewState, layer: MapLayer, visible: bool) { + match layer { + MapLayer::Ground => view.show_ground = visible, + MapLayer::Buildings => view.show_buildings = visible, + MapLayer::Roofs => view.show_roofs = visible, + MapLayer::InternalSprites => view.show_internal_sprites = visible, + MapLayer::Collisions => view.show_collisions = visible, + MapLayer::Events => view.show_events = visible, + MapLayer::Monsters => view.show_monsters = visible, + MapLayer::Npcs => view.show_npcs = visible, + MapLayer::NpcWaypoints => view.show_npc_waypoints = visible, + MapLayer::Objects => view.show_objects = visible, + MapLayer::DrawItems => view.show_draw_items = visible, + MapLayer::ObjectIds => view.show_object_ids = visible, + } +} + +/// Select the active canvas tool. If the tool's owning layer is hidden, +/// force-enable it (visibility ≠ editability: selecting an editing tool +/// implies you need to see what you edit). +pub fn select_tool(app: &mut App, tab_id: usize, tool: MapTool) -> Task { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { + if let Some(layer) = tool.owning_layer() + && !layer_visible(&state.view, layer) + { + set_layer_visible(&mut state.view, layer, true); + state.view.overlay_cache.clear(); + state.view.tile_layer_cache.clear(); + } + state.view.active_tool = tool; + } + Task::none() +} + +/// Toggle the collision flag on a tile. Returns `true` when the map changed. +pub fn toggle_collision_at( + state: &mut crate::editors::map_editor::state::MapEditorState, + tx: i32, + ty: i32, +) -> bool { + if !state.data.can_mutate_map_data() { + state.data.status_msg = + Some("Cannot edit collision while save/export is in progress".into()); + return false; + } + let LoadingState::Loaded(ref mut handle) = state.data.loading_state else { + return false; + }; + let map_data = + Arc::get_mut(&mut handle.0).expect("MapData Arc has unexpected shared reference"); + let old = map_data.collisions.get(&(tx, ty)).copied().unwrap_or(false); + map_data.collisions.insert((tx, ty), !old); + state.push_undo(MapEditAction { + entity: SelectedEntity::CollisionTile(tx, ty), + field: "collision".into(), + old_value: old.to_string(), + new_value: (!old).to_string(), + }); + state.view.selected_entity = None; + state.view.overlay_cache.clear(); + true +} + +/// Apply the object-id brush to a tile according to the current brush mode. +/// +/// Paint writes the brush value (overwriting whatever was there); Erase removes +/// the entry regardless of its current value. Returns `true` when changed. +pub fn apply_object_id_edit( + state: &mut crate::editors::map_editor::state::MapEditorState, + tx: i32, + ty: i32, +) -> bool { + if !state.data.can_mutate_map_data() { + state.data.status_msg = + Some("Cannot edit object IDs while save/export is in progress".into()); + return false; + } + let LoadingState::Loaded(ref mut handle) = state.data.loading_state else { + return false; + }; + let erase = state.view.object_brush_mode == ObjectBrushMode::Erase; + let brush = state.data.object_brush.clamp(1, 511); + let map_data = + Arc::get_mut(&mut handle.0).expect("MapData Arc has unexpected shared reference"); + let old = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0); + let new = if erase { 0 } else { brush }; + if new == 0 { + map_data.object_ids.remove(&(tx, ty)); + } else { + map_data.object_ids.insert((tx, ty), new); + } + state.push_undo(MapEditAction { + entity: SelectedEntity::ObjectIdTile(tx, ty), + field: "object_id".into(), + old_value: old.to_string(), + new_value: new.to_string(), + }); + state.view.selected_entity = None; + state.view.overlay_cache.clear(); + state.view.tile_layer_cache.clear(); + true +} + pub fn open(app: &mut App, tab_id: usize, path: PathBuf) -> Task { // Ensure item lookups are loaded for composite-item pickers in the // entity-inspector panels (MonsterRef, NPC, ExtraRef). @@ -784,4 +909,141 @@ mod tests { assert_eq!(bundle.all_map_id, None); assert_eq!(bundle.monsters.len(), 0); } + + // ── Tool selection & object-id brush ────────────────────────────────────── + + use crate::app::App; + use crate::editors::map_editor::state::MapEditorState; + use dispel_core::map::{MapData, MapModel}; + + const TAB: usize = 7; + + /// App with a map editor tab whose map data is loaded (8×8 tiles). + fn app_with_loaded_map(object_ids: HashMap<(i32, i32), i32>) -> App { + let mut app = App::new().0; + let model = MapModel { + border_count: 2, + tiled_map_width: 8, + tiled_map_height: 8, + map_width_in_pixels: 16 * 32, + map_height_in_pixels: 16 * 16, + map_non_occluded_start_x: 0, + map_non_occluded_start_y: 0, + occluded_map_in_pixels_width: 8 * 64, + occluded_map_in_pixels_height: 8 * 32, + }; + let map_data = MapData { + model, + gtl_tiles: HashMap::new(), + btl_tiles: HashMap::new(), + collisions: HashMap::new(), + events: HashMap::new(), + object_ids, + tiled_infos: vec![], + internal_sprites: vec![], + sprite_blocks: vec![], + }; + let state = app.state.editors.map_editors.entry(TAB).or_default(); + state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); + app + } + + #[test] + fn test_select_tool_collision_enables_layer() { + let mut app = app_with_loaded_map(HashMap::new()); + assert!(!layer_visible( + &app.state.editors.map_editors[&TAB].view, + MapLayer::Collisions + )); + + let _ = select_tool(&mut app, TAB, MapTool::Collision); + + let state = &app.state.editors.map_editors[&TAB]; + assert_eq!(state.view.active_tool, MapTool::Collision); + assert!( + layer_visible(&state.view, MapLayer::Collisions), + "selecting the Collision tool must force-enable the Collisions layer" + ); + } + + #[test] + fn test_hide_layer_resets_tool_to_pan() { + let mut app = app_with_loaded_map(HashMap::new()); + let _ = select_tool(&mut app, TAB, MapTool::ObjectId); + assert_eq!( + app.state.editors.map_editors[&TAB].view.active_tool, + MapTool::ObjectId + ); + + // Hide the layer that owns the active tool via LayerToggled. + let msg = MapEditorMessage::LayerToggled(TAB, MapLayer::ObjectIds); + let _ = super::super::handle(msg, &mut app); + + assert_eq!( + app.state.editors.map_editors[&TAB].view.active_tool, + MapTool::Pan, + "hiding the owning layer must reset the tool to Pan" + ); + } + + #[test] + fn test_object_id_paint_writes_brush() { + let mut app = app_with_loaded_map(HashMap::from([((5, 5), 7)])); + let state = app.state.editors.map_editors.get_mut(&TAB).unwrap(); + state.data.object_brush = 3; + state.view.object_brush_mode = ObjectBrushMode::Paint; + + assert!(apply_object_id_edit(state, 5, 5)); + let state = app.state.editors.map_editors.get_mut(&TAB).unwrap(); + if let LoadingState::Loaded(ref handle) = state.data.loading_state { + assert_eq!( + handle.0.object_ids.get(&(5, 5)), + Some(&3), + "paint overwrites existing value" + ); + } else { + panic!("map not loaded"); + } + assert_eq!(state.data.undo_stack.len(), 1); + } + + #[test] + fn test_object_id_erase_removes_any_value() { + let mut app = app_with_loaded_map(HashMap::from([((5, 5), 7)])); + let state = app.state.editors.map_editors.get_mut(&TAB).unwrap(); + state.data.object_brush = 7; // same value as existing entry — erase must still remove + state.view.object_brush_mode = ObjectBrushMode::Erase; + + assert!(apply_object_id_edit(state, 5, 5)); + let state = app.state.editors.map_editors.get_mut(&TAB).unwrap(); + if let LoadingState::Loaded(ref handle) = state.data.loading_state { + assert!( + !handle.0.object_ids.contains_key(&(5, 5)), + "erase removes the entry regardless of its value" + ); + } else { + panic!("map not loaded"); + } + } + + #[test] + fn test_brush_stepper_clamps_at_bounds() { + let mut app = app_with_loaded_map(HashMap::new()); + + for v in [999, 600] { + let msg = MapEditorMessage::SetObjectBrush(TAB, v); + let _ = super::super::handle(msg, &mut app); + assert_eq!(app.state.editors.map_editors[&TAB].data.object_brush, 511); + } + for v in [0, -5] { + let msg = MapEditorMessage::SetObjectBrush(TAB, v); + let _ = super::super::handle(msg, &mut app); + assert_eq!(app.state.editors.map_editors[&TAB].data.object_brush, 1); + } + } + + #[allow(dead_code)] + fn _assert_state_type(s: &MapEditorState) -> &MapEditorState { + s + } } diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 55707551..27868f49 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -1,10 +1,8 @@ use crate::app::App; -use crate::components::loading_state::LoadingState; -use crate::editors::map_editor::canvas::find_hovered_element; -use crate::editors::map_editor::{MapEditAction, MapEditorMessage, MapLayer, SelectedEntity}; +use crate::editors::map_editor::canvas::{find_hovered_element, find_tile_at}; +use crate::editors::map_editor::{MapEditorMessage, MapLayer, MapTool, SelectedEntity}; use crate::message::{Message, MessageExt}; use iced::Task; -use std::sync::Arc; mod conversation; mod dialog; @@ -198,6 +196,14 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { MapLayer::DrawItems => state.view.show_draw_items = !state.view.show_draw_items, MapLayer::ObjectIds => state.view.show_object_ids = !state.view.show_object_ids, } + // Hiding the layer that owns the active editing tool makes that + // tool meaningless — reset to Pan. + if let Some(owner) = state.view.active_tool.owning_layer() + && owner == layer + && !map::layer_visible(&state.view, layer) + { + state.view.active_tool = MapTool::Pan; + } // Tile canvas renders entities and tile layers; overlay renders // collisions and events — clear both caches. state.view.tile_layer_cache.clear(); @@ -233,86 +239,56 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { MapEditorMessage::CanvasClicked(tab_id, cx, cy) => { if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { - // Use find_hovered_element which also detects collision and event - // tiles. - let clicked = find_hovered_element(state, cx, cy); - state.data.status_msg = Some(match clicked { - Some(SelectedEntity::CollisionTile(tx, ty)) => { - format!("Collision tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::EventTile(tx, ty)) => { - format!("Event tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::ObjectIdTile(tx, ty)) => { - format!("Object ID tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::Monster(i)) => format!("Monster {} detected", i), - Some(SelectedEntity::Npc(i)) => format!("NPC {} detected", i), - Some(SelectedEntity::Extra(i)) => format!("Extra {} detected", i), - Some(SelectedEntity::DrawItem(i)) => { - format!("Draw item {} detected", i) - } - None => { - format!("Clicked at ({:.0},{:.0}) — no tile detected", cx, cy) + // Tool routing comes FIRST: the active tool decides what a + // click means. Layer visibility no longer gates editing. + match state.view.active_tool { + MapTool::Pan => { + // Selection only — never mutates. + let clicked = find_hovered_element(state, cx, cy); + state.data.status_msg = Some(match &clicked { + Some(SelectedEntity::CollisionTile(tx, ty)) => { + format!("Collision tile ({},{}) detected!", tx, ty) + } + Some(SelectedEntity::EventTile(tx, ty)) => { + format!("Event tile ({},{}) detected!", tx, ty) + } + Some(SelectedEntity::ObjectIdTile(tx, ty)) => { + format!("Object ID tile ({},{}) detected!", tx, ty) + } + Some(SelectedEntity::Monster(i)) => format!("Monster {} detected", i), + Some(SelectedEntity::Npc(i)) => format!("NPC {} detected", i), + Some(SelectedEntity::Extra(i)) => format!("Extra {} detected", i), + Some(SelectedEntity::DrawItem(i)) => { + format!("Draw item {} detected", i) + } + None => { + format!("Clicked at ({:.0},{:.0}) — no tile detected", cx, cy) + } + }); + state.view.selected_entity = clicked; + state.view.overlay_cache.clear(); } - }); - match clicked { - Some(SelectedEntity::CollisionTile(tx, ty)) => { - if !state.data.can_mutate_map_data() { - state.data.status_msg = Some( - "Cannot edit collision while save/export is in progress".into(), - ); - } else if let LoadingState::Loaded(ref mut handle) = - state.data.loading_state + MapTool::Collision => { + if let Some((tx, ty)) = find_tile_at(state, cx, cy) + && map::toggle_collision_at(state, tx, ty) { - let map_data = Arc::get_mut(&mut handle.0) - .expect("MapData Arc has unexpected shared reference"); - let old = map_data.collisions.get(&(tx, ty)).copied().unwrap_or(false); - map_data.collisions.insert((tx, ty), !old); - state.push_undo(MapEditAction { - entity: SelectedEntity::CollisionTile(tx, ty), - field: "collision".into(), - old_value: old.to_string(), - new_value: (!old).to_string(), - }); - state.view.selected_entity = None; - state.view.overlay_cache.clear(); set_tab_modified(app, tab_id, true); } } - Some(SelectedEntity::ObjectIdTile(tx, ty)) => { - if !state.data.can_mutate_map_data() { - state.data.status_msg = Some( - "Cannot edit object IDs while save/export is in progress".into(), - ); - } else if let LoadingState::Loaded(ref mut handle) = - state.data.loading_state + MapTool::ObjectId => { + if let Some((tx, ty)) = find_tile_at(state, cx, cy) + && map::apply_object_id_edit(state, tx, ty) { - let brush = state.data.object_brush.clamp(1, 511); - let map_data = Arc::get_mut(&mut handle.0) - .expect("MapData Arc has unexpected shared reference"); - let old = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0); - let new = if old == brush { 0 } else { brush }; - if new == 0 { - map_data.object_ids.remove(&(tx, ty)); - } else { - map_data.object_ids.insert((tx, ty), new); - } - state.push_undo(MapEditAction { - entity: SelectedEntity::ObjectIdTile(tx, ty), - field: "object_id".into(), - old_value: old.to_string(), - new_value: new.to_string(), - }); - state.view.selected_entity = None; - state.view.overlay_cache.clear(); - state.view.tile_layer_cache.clear(); set_tab_modified(app, tab_id, true); } } - _ => { - state.view.selected_entity = clicked; - state.view.overlay_cache.clear(); + MapTool::EventInspect => { + if let Some((tx, ty)) = find_tile_at(state, cx, cy) { + state.data.status_msg = + Some(format!("Event tile ({},{}) — inspecting", tx, ty)); + state.view.selected_entity = Some(SelectedEntity::EventTile(tx, ty)); + state.view.overlay_cache.clear(); + } } } } @@ -378,6 +354,15 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { Task::none() } + MapEditorMessage::SelectTool(tab_id, tool) => map::select_tool(app, tab_id, tool), + + MapEditorMessage::SetObjectBrushMode(tab_id, mode) => { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { + state.view.object_brush_mode = mode; + } + Task::none() + } + MapEditorMessage::SetObjectBrush(tab_id, value) => { if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { state.data.object_brush = value.clamp(1, 511); diff --git a/dispel-gui/src/editors/map_editor/view/inspector.rs b/dispel-gui/src/editors/map_editor/view/inspector.rs index b74814da..83a575a7 100644 --- a/dispel-gui/src/editors/map_editor/view/inspector.rs +++ b/dispel-gui/src/editors/map_editor/view/inspector.rs @@ -184,12 +184,17 @@ pub fn build_inspector<'a>( text(format!("Object ID at ({}, {})", tx, ty)).size(12), horizontal_rule(1), row![ - text("object_id").size(11).width(140.0).style(style::subtle_text), + text("object_id") + .size(11) + .width(140.0) + .style(style::subtle_text), text(current_id.to_string()).size(11), ] .spacing(6) .align_y(iced::Alignment::Center), - text("Use the brush toolbar to paint object IDs.").size(11).style(style::subtle_text), + text("Use the brush toolbar to paint object IDs.") + .size(11) + .style(style::subtle_text), ] .spacing(8) .into() diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index 51533004..369ed5c5 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -1,12 +1,14 @@ use super::canvas::{MapCanvasOverlaysLayer, MapCanvasTilesLayer}; -use super::message::{MapEditorMessage, MapLayer, MapViewMode}; +use super::message::{MapEditorMessage, MapLayer, MapTool, MapViewMode, ObjectBrushMode}; use crate::app::App; use crate::components::loading_state::LoadingState; use crate::message::{Message, MessageExt}; use crate::style; use gui_widgets::components::modal::modal; use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; -use iced::widget::{button, canvas, column, container, progress_bar, row, stack, text, text_input, toggler}; +use iced::widget::{ + button, canvas, column, container, progress_bar, row, stack, text, text_input, toggler, +}; use iced::{Element, Fill}; use lucide_icons::Icon; @@ -93,14 +95,6 @@ pub fn view(app: &App) -> Element<'_, Message> { ), info_cell("GTL", >l_count.to_string()), info_cell("BTL", &btl_count.to_string()), - info_cell( - "Handles", - &format!( - "{}/{}", - state.data.gtl_handles.len(), - state.data.btl_handles.len() - ) - ), info_cell( "Entities", &format!( @@ -111,43 +105,19 @@ pub fn view(app: &App) -> Element<'_, Message> { state.data.draw_items.len() ) ), - info_cell( - "Tiles", - &format!( - "{} GTL + {} BTL loaded", - state.data.gtl_handles.len(), - state.data.btl_handles.len() - ) - ), ] .spacing(16) .padding([8, 16]); - // ── Layer toggles ───────────────────────────────────────────── + // ── Layer toggles, grouped into three segments ──────────────── let tile_status = if state.data.tiles_ready { text("").size(10).style(style::subtle_text) } else { text("Decoding tiles…").size(10).style(style::subtle_text) }; - let obj_id_brush: Element<'_, Message> = { - let brush_str = state.data.object_brush.to_string(); - row![ - text("Brush:").size(10).style(style::subtle_text), - text_input("1", brush_str) - .width(iced::Length::Fixed(48.0)) - .on_input(move |v: String| { - let val = v.parse::().unwrap_or(1).clamp(1, 511); - Message::map_editor(MapEditorMessage::SetObjectBrush(tab_id, val)) - }), - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into() - }; - - let layer_row = row![ - text("Layers:").size(11).style(style::subtle_text), + let terrain_segment = row![ + segment_label("Terrain"), layer_toggle( "Ground", state.view.show_ground, @@ -156,7 +126,7 @@ pub fn view(app: &App) -> Element<'_, Message> { None ), layer_toggle( - "Buildings", + "Bldg", state.view.show_buildings, tab_id, MapLayer::Buildings, @@ -176,6 +146,12 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::InternalSprites, None ), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + let overlay_segment = row![ + segment_label("Overlays"), layer_toggle( "Collisions", state.view.show_collisions, @@ -191,21 +167,34 @@ pub fn view(app: &App) -> Element<'_, Message> { None ), layer_toggle( - "Monsters", + "Obj IDs", + state.view.show_object_ids, + tab_id, + MapLayer::ObjectIds, + None + ), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + let entity_segment = row![ + segment_label("Entities"), + layer_toggle( + "Mon", state.view.show_monsters, tab_id, MapLayer::Monsters, Some(state.data.monsters.len()) ), layer_toggle( - "NPCs", + "NPC", state.view.show_npcs, tab_id, MapLayer::Npcs, Some(state.data.npcs.len()) ), layer_toggle( - "Waypoints", + "Wpts", state.view.show_npc_waypoints, tab_id, MapLayer::NpcWaypoints, @@ -225,14 +214,17 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::DrawItems, Some(state.data.draw_items.len()) ), - layer_toggle( - "Obj IDs", - state.view.show_object_ids, - tab_id, - MapLayer::ObjectIds, - None - ), - obj_id_brush, + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + let layer_row = row![ + terrain_segment, + rule(), + overlay_segment, + rule(), + entity_segment, + tile_status, ] .spacing(12) .padding([6, 16]) @@ -301,6 +293,15 @@ pub fn view(app: &App) -> Element<'_, Message> { text("").size(10).style(style::subtle_text) }; + // Folded summary replacing the duplicate Handles/Tiles info cells. + let summary_text = format!( + "{}×{} · {} NPC · {} gtl", + model.tiled_map_width, + model.tiled_map_height, + state.data.npcs.len(), + state.data.gtl_handles.len() + ); + let action_row = row![ save_btn, undo_btn, @@ -308,11 +309,121 @@ pub fn view(app: &App) -> Element<'_, Message> { export_btn, tmx_btn, status_text, + horizontal_space(), + text(summary_text).size(10).style(style::subtle_text), ] .spacing(6) .padding([4, 16]) .align_y(iced::Alignment::Center); + // ── Tools row ──────────────────────────────────────────────────── + let tool_chip = |label: &'static str, tool: MapTool| { + button(text(label).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SelectTool( + tab_id, tool, + ))) + .padding([3, 10]) + .style(if state.view.active_tool == tool { + style::active_chip + } else { + style::chip + }) + }; + + let tools_row = row![ + text("Tools:").size(11).style(style::subtle_text), + tool_chip("Pan", MapTool::Pan), + tool_chip("Collide", MapTool::Collision), + tool_chip("Obj ID", MapTool::ObjectId), + tool_chip("Event", MapTool::EventInspect), + ] + .spacing(4) + .padding([6, 16]) + .align_y(iced::Alignment::Center); + + // ── Contextual object-id brush options (Obj ID tool only) ───── + let obj_brush_options: Element<'_, Message> = if state.view.active_tool + == MapTool::ObjectId + { + let mode_chip = |label: &'static str, mode: ObjectBrushMode| { + button(text(label).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrushMode( + tab_id, mode, + ))) + .padding([3, 8]) + .style(if state.view.object_brush_mode == mode { + style::active_chip + } else { + style::chip + }) + }; + let brush = state.data.object_brush; + let preset_chip = |n: i32| { + button(text(n.to_string()).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, n, + ))) + .padding([3, 8]) + .style(style::chip) + }; + let brush_str = brush.to_string(); + row![ + mode_chip("Paint", ObjectBrushMode::Paint), + mode_chip("Erase", ObjectBrushMode::Erase), + rule(), + button(text("−").size(12)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, + brush - 1 + ))) + .padding([3, 8]) + .style(style::chip), + container( + text_input("1", brush_str) + .width(iced::Length::Fixed(48.0)) + .on_input(move |v: String| { + let val = v.parse::().unwrap_or(1).clamp(1, 511); + Message::map_editor(MapEditorMessage::SetObjectBrush(tab_id, val)) + }), + ) + .padding([2, 4]) + .style(style::info_card), + button(text("+").size(12)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, + brush + 1 + ))) + .padding([3, 8]) + .style(style::chip), + text("Presets:").size(10).style(style::subtle_text), + preset_chip(1), + preset_chip(2), + preset_chip(3), + preset_chip(5), + preset_chip(10), + ] + .spacing(4) + .padding([0, 16]) + .align_y(iced::Alignment::Center) + .into() + } else { + Element::new(horizontal_space()) + }; + + // Hint line describing what a click currently does. + let hint: String = match state.view.active_tool { + MapTool::Pan => "Click: select entity · drag: pan".into(), + MapTool::Collision => "Click tile: block/unblock".into(), + MapTool::ObjectId => match state.view.object_brush_mode { + ObjectBrushMode::Paint => { + format!("Click: paint obj {}", state.data.object_brush) + } + ObjectBrushMode::Erase => "Click: erase any value".into(), + }, + MapTool::EventInspect => "Click tile: inspect event".into(), + }; + let hint_row = row![text(hint).size(10).style(style::subtle_text)].padding([0, 16]); + let mode_tab_row = row![ button(text("Map").size(11)) .on_press(Message::map_editor(MapEditorMessage::SwitchViewMode( @@ -344,9 +455,10 @@ pub fn view(app: &App) -> Element<'_, Message> { column![ row![mode_tab_row, action_row].spacing(0), row![info_row].spacing(0), - row![layer_row, tile_status] - .spacing(16) - .align_y(iced::Alignment::Center), + tools_row, + obj_brush_options, + hint_row, + row![layer_row].spacing(0).align_y(iced::Alignment::Center), ] .spacing(0), ) @@ -447,6 +559,13 @@ pub fn view(app: &App) -> Element<'_, Message> { // ── Helpers ─────────────────────────────────────────────────────────────────── +use iced::widget::space::Space; + +/// Fill remaining horizontal space (pushes following widgets right). +fn horizontal_space() -> Space { + Space::new().width(Fill) +} + fn info_cell<'a>(label: &'static str, value: &str) -> Element<'a, Message> { column![ text(label).size(10).style(style::subtle_text), @@ -456,6 +575,27 @@ fn info_cell<'a>(label: &'static str, value: &str) -> Element<'a, Message> { .into() } +/// Thin vertical separator between layer-toggle segments. +fn rule() -> Element<'static, Message> { + container(text("")) + .width(iced::Length::Fixed(1.0)) + .height(iced::Length::Fixed(16.0)) + .style(|_| container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.6, 0.55, 0.45, 0.4, + ))), + ..container::Style::default() + }) + .into() +} + +fn segment_label(label: &'static str) -> Element<'static, Message> { + text(label.to_string()) + .size(10) + .style(style::subtle_text) + .into() +} + fn layer_toggle( label: &'static str, is_on: bool, @@ -464,7 +604,7 @@ fn layer_toggle( count: Option, ) -> Element<'static, Message> { let label_str: String = match count { - Some(n) => format!("{} ({})", label, n), + Some(n) => format!("{}({})", label, n), None => label.to_string(), }; toggler(is_on) From 09e9dbc8d6508055c109cc6696969ae6ef7b8f9a Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:17:25 +0200 Subject: [PATCH 08/49] Register Save.ifo parser --- src/commands/registry/detect.rs | 1 + src/commands/registry/entries.rs | 13 +++++++++++++ src/lib.rs | 1 + 3 files changed, 15 insertions(+) diff --git a/src/commands/registry/detect.rs b/src/commands/registry/detect.rs index 85ed4c5a..c9be5e9e 100644 --- a/src/commands/registry/detect.rs +++ b/src/commands/registry/detect.rs @@ -49,6 +49,7 @@ pub static FILE_TYPES: LazyLock> = LazyLock::new(|| { entries::make_sprite(), // Save files entries::make_save_file(), + entries::make_save_ifo(), ] }); diff --git a/src/commands/registry/entries.rs b/src/commands/registry/entries.rs index 14905e9b..8a48bb19 100644 --- a/src/commands/registry/entries.rs +++ b/src/commands/registry/entries.rs @@ -421,6 +421,19 @@ pub(crate) fn make_btl() -> FileType { } } +pub(crate) fn make_save_ifo() -> FileType { + FileType { + key: "save_ifo", + name: "Save.ifo", + description: "Save-slot metadata index (timestamps and occupancy for 0.sav..5.sav)", + extensions: &[".ifo"], + detect_kind: DetectKind::Db(&["Save.ifo"]), + extract_fn: extract_as::, + patch_fn: patch_as::, + validate_fn: None, + } +} + pub(crate) fn make_sprite() -> FileType { FileType { key: "sprite", diff --git a/src/lib.rs b/src/lib.rs index 37f40e7e..d63f44e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,7 @@ pub use references::{ CharacterIdentity, InventoryData, JournalData, JournalEntry, MapSectionData, MapViewportCell, MapViewportState, PostEventsData, PostMapsData, SaveFile, }, + save_ifo::{SaveIfo, SaveSlotInfo}, store_db::Store, wave_ini::WaveIni, weapons_db::WeaponItem, From f67c69bd2aaaacf586a7f99b434744e4abe6076f Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:20:03 +0200 Subject: [PATCH 09/49] Add Save.ifo docs --- docs/files/Save.ifo.md | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/files/Save.ifo.md diff --git a/docs/files/Save.ifo.md b/docs/files/Save.ifo.md new file mode 100644 index 00000000..3ceacb87 --- /dev/null +++ b/docs/files/Save.ifo.md @@ -0,0 +1,51 @@ +# Save.ifo + +> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark +> owner. + +## Purpose + +`Save.ifo` is the save-slot metadata index. It describes the six save slots (`0.sav` … `5.sav`): whether each slot is +occupied and when it was last written. It also snapshots a small amount of global state needed to resume the session +from the `game.tmp` append-log. + +## File Structure + +- **Location**: `Save.ifo` (game root directory) +- **Encoding**: binary, little-endian +- **Format**: fixed size of 224 bytes — six 32-byte slot records followed by a 32-byte global tail, + addressed as two blocks: bytes 0–191 (slots) and 192–223 (tail). + +### Slot records (offsets 0–191, record *p* at *p* × 32) + +| Rel off | Type | Meaning | +|---------|---------|------------------------------------------------------------------------------------------------------| +| +0 | u8[12] | reserved — always zero in known files | +| +12 | u32 | save time: month | +| +16 | u32 | day | +| +20 | u32 | hour | +| +24 | u32 | minute | +| +28 | u8[4] | flags: byte 0 = slot-occupied flag (1 = used), remaining 3 padding | + +### Global tail (offsets 192–223) + +| Off | Type | Meaning | +|-----|------|--------------------------------------------------------------------------------------------------| +| 192 | f32 | unknown float — written as constant 1.4 before every save | +| 196 | u32 | `game.tmp` journal key identifying the current session's payload | +| 200 | u32 | map/world id active at save time | +| 204 | u32 | unknown; zero by default | +| 208 | u32 | payload element count A — snapshot used as size multiplier when traversing the `game.tmp` log | +| 212 | u32 | payload element count B (same role) | +| 216 | u32 | payload element count C (same role) | +| 220 | u32 | payload element count D (same role) | + +## Usage + +Written on every save alongside the `%d.sav` payload file. The save/load menu reads it at startup and lists each +occupied slot as `MM / DD HH : MM`. The tail snapshots the state needed to traverse the current session's payloads +inside the `game.tmp` append-log. + +## Parser + +The Rust parser is in `src/references/save_ifo.rs` (`SaveIfo` / `SaveSlotInfo` structs, `Extractor` trait). From bcec0f06b42a20c955fb60b4efe72ba35465c7e2 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:22:36 +0200 Subject: [PATCH 10/49] Add some findings about the unknown bytes in the Map file --- .../map_editor/canvas/render_overlays.rs | 4 +- .../src/editors/map_editor/canvas/tests.rs | 10 +- .../src/editors/map_editor/update/entity.rs | 15 +- .../src/editors/map_editor/update/map.rs | 1 + .../src/editors/map_editor/view/inspector.rs | 2 +- docs/files/Map/Map.map.md | 54 +++-- docs/files/Map/MapModule.md | 186 ++++++++++-------- src/map/mod.rs | 105 ++++++---- src/map/reader.rs | 159 +++++++++------ src/map/render.rs | 16 +- src/map/tmx.rs | 13 +- src/map/types.rs | 48 ++++- src/map/writer.rs | 47 +++-- 13 files changed, 398 insertions(+), 262 deletions(-) diff --git a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs index 4a562766..f1377268 100644 --- a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs +++ b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs @@ -99,7 +99,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { // Event overlay if self.state.view.show_events { for (&(tx, ty), event) in &map_data.events { - if event.event_id == 0 { + if event.event_id() == 0 { continue; } let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); @@ -115,7 +115,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { ); let label_size = (11.0 * zoom).max(6.0); frame.fill_text(CanvasText { - content: event.event_id.to_string(), + content: event.event_id().to_string(), position: Point::new(ecx, ecy - 10.0 * zoom), color: Color::WHITE, size: iced::Pixels(label_size), diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index 6d98fbae..29fd181b 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -54,6 +54,7 @@ fn make_state( model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), + access_ref_words: HashMap::new(), collisions, events, object_ids: HashMap::new(), @@ -632,8 +633,7 @@ fn test_hovered_element_entity_over_event() { EventBlock { x: 10, y: 10, - _unknown_value: 0, - event_id: 1, + word: 1, }, ); let state = make_state( @@ -693,8 +693,7 @@ fn test_hovered_element_event_when_no_entity_or_collision() { EventBlock { x: 15, y: 25, - _unknown_value: 0, - event_id: 42, + word: 42, }, ); let state = make_state( @@ -741,8 +740,7 @@ fn test_hovered_element_respects_layer_visibility() { EventBlock { x: 3, y: 4, - _unknown_value: 0, - event_id: 99, + word: 99, }, ); let state = make_state( diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 096006cb..68182ba2 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -47,7 +47,7 @@ pub fn field_changed( SelectedEntity::EventTile(tx, ty) => state .map_data() .and_then(|h| h.0.events.get(&(tx, ty))) - .map(|e| e.event_id.to_string()) + .map(|e| e.event_id().to_string()) .unwrap_or_default(), SelectedEntity::CollisionTile(_, _) => String::new(), SelectedEntity::ObjectIdTile(_, _) => String::new(), @@ -95,10 +95,9 @@ pub fn field_changed( let ev = map_data.events.entry((tx, ty)).or_insert(EventBlock { x: tx, y: ty, - _unknown_value: 0, - event_id: 0, + word: 0, }); - ev.event_id = value.parse::().unwrap_or(0); + ev.set_event_id(value.parse::().unwrap_or(0) as u16); } else { entity_mutated = false; } @@ -202,10 +201,9 @@ pub fn undo(app: &mut App, tab_id: usize) -> Task { .or_insert(dispel_core::map::EventBlock { x: tx, y: ty, - _unknown_value: 0, - event_id: 0, + word: 0, }); - ev.event_id = val; + ev.set_event_id(val as u16); } } SelectedEntity::ObjectIdTile(tx, ty) => { @@ -294,7 +292,7 @@ pub fn redo(app: &mut App, tab_id: usize) -> Task { .expect("MapData Arc has unexpected shared reference"); let val = action.new_value.parse::().unwrap_or(0); if let Some(ev) = map_data.events.get_mut(&(tx, ty)) { - ev.event_id = val; + ev.set_event_id(val as u16); } } } @@ -506,6 +504,7 @@ mod tests { model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), + access_ref_words: HashMap::new(), collisions: HashMap::new(), events: HashMap::new(), object_ids, diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index c0a5f8e4..43f47f25 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -936,6 +936,7 @@ mod tests { model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), + access_ref_words: HashMap::new(), collisions: HashMap::new(), events: HashMap::new(), object_ids, diff --git a/dispel-gui/src/editors/map_editor/view/inspector.rs b/dispel-gui/src/editors/map_editor/view/inspector.rs index 83a575a7..fc6eb913 100644 --- a/dispel-gui/src/editors/map_editor/view/inspector.rs +++ b/dispel-gui/src/editors/map_editor/view/inspector.rs @@ -93,7 +93,7 @@ pub fn build_inspector<'a>( if let Some(event) = map_data.events.get(&(tx, ty)) { let x_str = event.x.to_string(); let y_str = event.y.to_string(); - let event_id_val = event.event_id.to_string(); + let event_id_val = event.event_id().to_string(); container( column![ diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index bb9a2e5f..9723dc37 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -60,7 +60,8 @@ records: (count − 1) × 8 bytes -- each record: value1: i32, value2: i32 ``` Skipping `(count − 1) × 8` lands exactly on the next block's size field, verified against the cat1/cat3/dun01/map1/catp -fixtures. The pairs appear unused by our tools; they are kept here for format completeness only. +fixtures. In-game, each record's second i32 (`value2`) is a **linear tile index** (`y × stride + x`) into the three +end grids — the game's tiled-object renderer and access checks resolve tiles through it. ### Second Block *(u16 lookup table)* @@ -69,8 +70,10 @@ size: i32 data: size × 2 bytes -- table of u16 entries ``` -Not dead data: the Access-Ref block indexes into this table, and each entry's low byte acts as a boolean flag consumed -by occlusion/access checks. All non-zero ids observed fall inside the table bounds. +Not dead data: the Access-Ref block indexes into this table. Each u16 entry is +`{low byte: transparency mode (0 = opaque copy, 1 = skip transparent pixels), +high byte: draw-enable flag}`. All non-zero ids observed fall inside the table +bounds. ### Sprite Block *(embedded sprites)* @@ -93,15 +96,20 @@ rather than tile grid. placement_count: i32 for each placement: sprite_id: i32 -- index into the Sprite Block - unknown: 2 × i32 - bottom_right_x: i32 -- Y-sort anchor (see below) - bottom_right_y: i32 - x: i32 -- top-left placement, map-local pixels - y: i32 + bbox_left: i32 -- frame bounding box in map-local pixels + bbox_top: i32 + bbox_right: i32 -- == left + frame width + bottom_right_y: i32 -- == top + frame height (Y-sort key) + x: i32 -- duplicates bbox_left + y: i32 -- duplicates bbox_top frame_skip: (frame_count − 1) × 6 × 4 bytes ``` -`bottom_right_y` equals `y + sprite_height` and serves as the depth key for interlaced rendering. +The seven i32s after `sprite_id` are actually **frame 0 of a per-frame record** +(24 bytes each: `{left, top, right, bottom}` box + duplicated `{x, y}` anchor; +the game stores them in separate per-frame arrays so they can diverge). +Verified against cat1/map1: `right − left` and `bottom − top` equal the frame's +pixel `width × height` exactly. Placements Y-sort by `bottom`. ### Tiled Objects Block *(buildings)* @@ -130,12 +138,16 @@ Negative ids occur and are skipped when drawing. One record per tile, row-major: ``` -event_id: i16 -- low 14 bits hold the id (ids < 70 are - considered valid; resolvable through the - Map.ini / AllMap.ini tables) -unknown: i16 -- parameters/flags, semantics TBD +event_id: i16 -- low half of a packed u32; low 14 bits hold + the id (ids < 70 are considered valid; + resolvable through Map.ini / AllMap.ini) +flags: i16 -- high half: parameter/flag bits ``` +In the packed u32, bit 22 acts as a "tile marked / entity occupied" flag (see +`EventBlock::is_tile_marked` in `src/map/mod.rs`); the remaining high bits are +unmapped parameters. + ### Tile & Access Block One packed u32 per tile, row-major (bit layout verified against shipped map data): @@ -160,11 +172,21 @@ One record per tile, row-major: ``` ref_id: i16 -- bits 0–14: index into the Second Block table -flag: i16 -- rarely non-zero (a handful of border tiles) +shadow_and_flags: i16 -- bits 15–29 of the u32: shadow level 0–199; + bits 30–31: light-source flags ``` -Despite the historical name, this grid drives **occlusion/access**, not visuals — roof pixels come from the Tiled -Objects block. Each referenced table entry's low byte is a boolean flag used by occlusion checks. +This grid packs **two layers** in one u32: + +1. **BTL overlay ref** (bits 0–14): when the referenced Second Block entry has + its *high* byte set, the game blits BTL pixels from + `btl_base + ref_id × 2048` (entry *low* byte selects transparent vs opaque + blit). So the Second Block is `{transparency, draw_enable}` per overlay id. +2. **Shadow/fog level** (bits 15–29): 0–199 darkness applied per tile via a + fade table; entities carrying light raise the value (max wins). + +Bit 15 of the word bleeds into the signed `ref_id` i16, which is why readers +skip negative ids. --- diff --git a/docs/files/Map/MapModule.md b/docs/files/Map/MapModule.md index a038960a..661508a3 100644 --- a/docs/files/Map/MapModule.md +++ b/docs/files/Map/MapModule.md @@ -18,16 +18,18 @@ The `src/map/` module handles parsing, rendering, and database operations for th ### Module Files -| File | Lines | Purpose | -|--------------------|-------|--------------------------------------------------------------------| -| `mod.rs` | 546 | Public API, top-level `.MAP` parser, CLI commands, DB import | -| `types.rs` | 39 | Coordinate types, constants, data structs | -| `model.rs` | 72 | Map geometry computation from chunk dimensions | -| `reader.rs` | 246 | Binary block readers for each `.MAP` section | -| `render.rs` | 460 | Isometric rendering pipeline (ground → objects → roofs) | -| `tileset.rs` | 418 | `.GTL`/`.BTL` tileset extraction and atlas generation | -| `database.rs` | 512 | Render from SQLite + external entity (NPC/monster/extra) rendering | -| `sprite_loader.rs` | 137 | `.SPR` file frame loading and sprite plotting | +| File | Purpose | +|--------------------|--------------------------------------------------------------------| +| `mod.rs` | Public API, top-level `.MAP` parser, CLI commands, DB import | +| `types.rs` | Coordinate types, constants, data structs | +| `model.rs` | Map geometry computation from chunk dimensions | +| `reader.rs` | Binary block readers for each `.MAP` section | +| `writer.rs` | `.MAP` binary writer (round-trip serialization) | +| `tmx.rs` | TMX (Tiled) map export | +| `render.rs` | Isometric rendering pipeline (ground → objects → roofs) | +| `tileset.rs` | `.GTL`/`.BTL` tileset extraction and atlas generation | +| `database.rs` | Render from SQLite + external entity (NPC/monster/extra) rendering | +| `sprite_loader.rs` | `.SPR` file frame loading and sprite plotting | --- @@ -41,70 +43,82 @@ tiles, collision data, and optional roof tiles. #### Structure ``` -+-----------------------------------+ -| HEADER (8 bytes) | -| tiled_map_chunk_width: i32 | ← number of 25×25 chunks on X axis -| tiled_map_chunk_height: i32 | ← number of 25×25 chunks on Y axis -+-----------------------------------+ -| FIRST BLOCK (unknown, skipped) | -| multiplier: i32 | -| size: i32 | -| data: multiplier*size*4 | ← skip this many bytes -+-----------------------------------+ -| SECOND BLOCK (unknown, skipped) | -| size: i32 | -| data: size*2 | ← skip this many bytes -+-----------------------------------+ -| SPRITE BLOCK | -| sprite_count: i32 | -| For each sprite: | -| image_stamp: i32 | ← 6 or 9 (determines data layout) -| metadata: 264 bytes | ← unknown purpose -| sequence_info: variable | ← frame count, positions, pixel data -| padding: 1904 or 2996 | ← depends on image_stamp -+-----------------------------------+ -| SPRITE INFO BLOCK | -| placement_count: i32 | -| For each placement: | -| sprite_id: i32 | ← index into sprite block -| unknown: i32 × 2 | -| bottom_right_x: i32 | -| bottom_right_y: i32 | -| sprite_x: i32 | ← pixel X position on map -| sprite_y: i32 | ← pixel Y position on map -| frame_skip_data: variable | ← (frame_count-1) × 6 × 4 bytes -+-----------------------------------+ -| TILED OBJECTS BLOCK | -| bundles_count: i32 | -| number1: i32 | -| For each bundle: | -| metadata: 264 bytes | -| s8, s0_1, s1, s0_2: i32 × 4 | -| v1..v4: i32 × 4 | -| x: i32 | ← tile X coordinate -| y: i32 | ← tile Y coordinate -| v7, v8: i32 × 2 | -| c1, c2, c3: i32 × 3 | ← counts for various data sections -| tile_ids: i16 × c3 | ← BTL tile IDs stacked vertically -| padding: 84 bytes | -| extra_data: (c1+c2+c3) × 4 | -| Sentinel alignment (20 bytes scan for the byte value 1) -+-----------------------------------+ -| EVENT BLOCK (read from end) | -| For each tile (width × height): | -| event_id: i16 | ← event trigger ID -| unknown_value: i16 | -+-----------------------------------+ -| TILE & ACCESS BLOCK | -| For each tile (width × height): | -| packed_value: i32 | ← bits 10..31 = GTL tile ID -| | ← bit 0 = collision flag -+-----------------------------------+ -| ROOF TILE BLOCK (optional) | -| For each tile (width × height): | -| btl_tile_id: i16 | ← BTL tile ID (0 = none) -| some_flag: i16 | ← unknown flag -+-----------------------------------+ ++-----------------------------------------------+ +| HEADER (12 bytes) | +| chunk_width: i32 ← number of 25×25 chunks on X | +| chunk_height: i32 ← number of 25×25 chunks on Y | +| border_count: i32 ← always 2 | ++-----------------------------------------------+ +| OBJECT-REF RECORDS (skipped by the parser) | +| count: i32 | +| records: (count-1) × {value1, value2} i32 | +| value2 = linear tile index into the three | +| end grids (y × stride + x) | ++-----------------------------------------------+ +| OVERLAY-ID TABLE (skipped by the parser) | +| size: i32 | +| entries: size × u16 | +| {low byte: transparency mode, | +| high byte: draw-enable} | +| indexed by the Access-Ref grid's refs | ++-----------------------------------------------+ +| SPRITE BLOCK | +| sprite_count: i32 | +| For each sprite: | +| image_stamp: i32 ← 6 or 9 | +| metadata: 264 bytes | +| sequence_info: variable | +| pixel_data: 1904 or 2996 bytes | ++-----------------------------------------------+ +| SPRITE INFO BLOCK | +| placement_count: i32 | +| For each placement: | +| sprite_id: i32 | +| frame-0 record (24 bytes): | +| bbox_left, bbox_top, | +| bbox_right, bbox_bottom i32 × 4 | +| ← bounding box in map-local pixels; | +| right-left = frame width, | +| bottom-top = frame height; | +| bottom is the Y-sort key | +| anchor_x, anchor_y i32 × 2 | +| ← duplicate left/top in shipped maps | +| remaining frames: (frame_count-1) × 24 B | ++-----------------------------------------------+ +| TILED OBJECTS BLOCK (buildings) | +| bundles_count: i32 | +| sub_record_count: i32 | +| For each bundle: | +| metadata: 264 bytes | +| control_0..3: i32 × 4 ← (8,0,1,0) | +| param_0..3: i32 × 4 ← unmapped | +| anchor_x, anchor_y: i32 ← map px | +| param_4..5: i32 × 2 ← unmapped | +| extra_count_a/b: i32 × 2 | +| tile_stack_len: i32 | +| tile_ids: i16 × tile_stack_len | +| ← BTL tiles stacked top → bottom | +| trailing: 84 bytes + (counts sum) × 4 | +| Sentinel alignment (20-byte scan for byte 1)| ++-----------------------------------------------+ +| EVENT GRID (end of file) | +| For each tile: packed u32 | +| bits 0-13 event id (ids < 70 valid) | +| bit 22 tile marked / entity occupies | +| remainder unmapped | ++-----------------------------------------------+ +| TILE & ACCESS GRID | +| For each tile: packed u32 | +| bit 0 collision | +| bits 1-9 object slot id | +| bits 10-24 GTL ground-tile index | ++-----------------------------------------------+ +| ACCESS-REF GRID ("roof") | +| For each tile: packed u32 | +| bits 0-14 overlay ref → overlay table | +| bits 15-29 shadow level (0-199) | +| bits 30-31 light-source flags | ++-----------------------------------------------+ ``` #### Computed Dimensions @@ -837,21 +851,21 @@ The following JSON schema describes the structure for exporting `.MAP` file data |--------------|-------|--------------------------|--------------------------------------------------------| | 0 | 4 | `chunk_width` | i32 LE | | 4 | 4 | `chunk_height` | i32 LE | -| 8 | 4 | `first_block_multiplier` | i32 LE | -| 12 | 4 | `first_block_size` | i32 LE | -| 16 | var | `first_block_data` | Skip `multiplier * size * 4` bytes | -| var | 4 | `second_block_size` | i32 LE | -| var | var | `second_block_data` | Skip `size * 2` bytes | +| 8 | 4 | `border_count` | i32 LE (always 2) | +| 12 | 4 | `object_ref_count` | i32 LE | +| 16 | var | `object_ref_records` | Skip `(count − 1) × 8` bytes; each record's `value2` is a linear tile index into the end grids | +| var | 4 | `overlay_table_size` | i32 LE | +| var | var | `overlay_table` | `size × 2` bytes; u16 entries `{transparency, draw_enable}` indexed by Access-Ref ids | | var | 4 | `sprite_count` | i32 LE | | var | var | `sprite entries` | Each: stamp(4) + meta(264) + sequence_info + padding | | var | 4 | `sprite_placement_count` | i32 LE | -| var | var | `sprite placements` | Each: 7×i32 + variable frame skip | +| var | var | `sprite placements` | Each: sprite_id(4) + frame-0 bbox {left,top,right,bottom} + dup anchor {x,y}, then `(frame_count − 1) × 24` bytes | | var | 4 | `tiled_object_count` | i32 LE | -| var | 4 | `tiled_object_number1` | i32 LE | -| var | var | `tiled objects` | Each: 264 + 8×i32 + 3×i32 + c3×i16 + 84 + (c1+c2+c3)×4 | -| EOF-(w×h×12) | w×h×4 | `event blocks` | Each: event_id(i16) + unknown(i16) | -| EOF-(w×h×8) | w×h×4 | `tile & access` | Each: packed_value(i32) | -| EOF-(w×h×4) | w×h×4 | `roof tiles` | Each: btl_id(i16) + flag(i16) | +| var | 4 | `tiled_object_subrecords`| i32 LE | +| var | var | `tiled objects` | Each: 264 + control(4×i32) + params(6×i32) + anchor(x,y) + counts(3×i32) + tile_stack(c3×i16) + 84 + (c1+c2+c3)×4 | +| EOF-(w×h×12) | w×h×4 | `event grid` | Each packed u32: low 14 bits = event id, high half = flags (bit 22 = tile marked) | +| EOF-(w×h×8) | w×h×4 | `tile & access` | Each packed u32: bit 0 collision, bits 1–9 object slot, bits 10–24 GTL tile index | +| EOF-(w×h×4) | w×h×4 | `access-ref ("roof")` | Each packed u32: bits 0–14 overlay id → overlay_table, bits 15–29 shadow level 0–199, bits 30–31 light flags | Where `w = tiled_map_width`, `h = tiled_map_height`. @@ -876,6 +890,6 @@ When `game_path` is not provided or sprite files are missing, entities are rende 3. **Tiled object sentinel** — the end of the tiled objects block is detected by scanning 20 bytes backwards for the byte value 1 4. **Roof block is optional** — only parsed if remaining file size is sufficient -5. **Collision flag** is extracted from bit 0 of the packed tile value; tile ID is bits 10–31 +5. **Collision flag** is extracted from bit 0 of the packed tile value; tile index is bits 10–24 6. **All integers are little-endian** 7. **Sprite sequences** in the map file are self-contained — pixel data is embedded directly, not referenced externally diff --git a/src/map/mod.rs b/src/map/mod.rs index 7adff114..08729589 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -28,13 +28,19 @@ // | FIRST BLOCK (variable) | // | - Count (i32) | // | - Data: (count-1)*8 bytes | -// | (count-1 records of 2×i32, | -// | purpose unknown, skipped) | +// | (count-1 records of 2×i32; | +// | value2 = linear tile index| +// | into the three end grids, | +// | used by tiled-object | +// | rendering) | // +------------------------------+ // | SECOND BLOCK (variable) | // | - Size (i32) | -// | - Data: size*2 | -// | (unknown purpose, skipped) | +// | - Data: size*2 (u16 table) | +// | (lookup table for the end- | +// | grid BTL overlay refs: | +// | hi byte = draw-enable flag, | +// | lo byte = transparency mode)| // +------------------------------+ // | SPRITE BLOCK | // | - Sprite count (i32) | @@ -48,33 +54,40 @@ // | - Placement count (i32) | // | For each placement: | // | - Sprite ID (i32) | -// | - Position data | -// | - Frame count | +// | - Frame-0 bbox in map px | +// | {left, top, right, | +// | bottom} + dup {x, y} | +// | - (frame_count-1)*24 bytes| // +------------------------------+ // | TILED OBJECTS BLOCK | // | - Bundle count (i32) | // | For each bundle: | // | - 264 bytes metadata | -// | - Coordinates (x,y) | -// | - Tile stack IDs | +// | - Control words + params | +// | - Anchor (x,y) map px | +// | - BTL tile stack IDs | // | - Building definition | // +------------------------------+ // | ... (file continues) ... | // +------------------------------+ -// | EVENT BLOCK (near end) | -// | For each tile (width×height):| -// | - Event ID (i16) | -// | - Unknown (i16) | +// | EVENT GRID (near end) | +// | For each tile: packed u32 | +// | bits 0-13 event id | +// | bit 22 tile marked | +// | remainder unmapped | // +------------------------------+ -// | TILE & ACCESS BLOCK | -// | For each tile (width×height):| -// | - GTL tile ID (i32) | -// | - Collision flag | +// | TILE & ACCESS GRID | +// | For each tile: packed u32 | +// | bit 0 collision | +// | bits 1-9 object slot id | +// | bits 10-24 GTL tile index | // +------------------------------+ -// | ROOF TILE BLOCK (optional) | -// | For each tile (width×height):| -// | - BTL tile ID (i16) | -// | - Flags (i16) | +// | ACCESS-REF GRID ("roof") | +// | For each tile: packed u32 | +// | bits 0-14 overlay id → | +// | second block | +// | bits 15-29 shadow level | +// | bits 30-31 light flags | // +------------------------------+ // // COORDINATE SYSTEM: @@ -84,7 +97,7 @@ // - Offsets: TILE_HORIZONTAL_OFFSET_HALF=32, TILE_HEIGHT_HALF=16 // // FILE SIZE CALCULATION: -// Total size = header + blocks + (width×height×(2+4+2)) + optional roof data +// Total size = header + blocks + width×height×12 (three packed-u32 grids) // pub mod database; pub mod model; @@ -123,8 +136,8 @@ use serde::{Deserialize, Serialize}; type IoResult = std::io::Result; use reader::{ - first_block, read_events_block, read_roof_tiles, read_tiles_and_access_block, second_block, - sprite_block, sprite_info_block, tiled_objects_block, + read_events_block, read_roof_tiles, read_tiles_and_access_block, skip_overlay_id_table, + skip_tiled_object_refs, sprite_block, sprite_info_block, tiled_objects_block, }; use render::{MapRenderConfig, render_map}; @@ -136,6 +149,10 @@ pub struct MapData { pub model: MapModel, pub gtl_tiles: HashMap, pub btl_tiles: HashMap, + /// Raw packed u32 words of the access-ref grid ("roof" block), preserved + /// so saving keeps shadow levels and light flags intact. Overlay refs are + /// patched from `btl_tiles` on write. + pub access_ref_words: HashMap, pub collisions: HashMap, pub events: HashMap, pub object_ids: HashMap, @@ -263,7 +280,7 @@ impl MapData { .map(|(&(x, y), event)| EventEntryJson { x, y, - event_id: event.event_id, + event_id: event.event_id() as i16, }) .collect(), object_ids: self @@ -336,13 +353,14 @@ impl MapData { /// # Parsing Process /// The function reads these blocks in order: /// 1. Map model header to determine dimensions -/// 2. Unknown blocks (skipped) +/// 2. Object-ref records + overlay-id table (skipped; see +/// `reader::skip_tiled_object_refs` / `reader::skip_overlay_id_table`) /// 3. Sprite block with embedded animation sequences -/// 4. Sprite placement information +/// 4. Sprite placement information (frame bounding boxes in map pixels) /// 5. Tiled objects (building definitions) -/// 6. Event triggers (read from end of file) -/// 7. Ground tiles and collision data -/// 8. Optional roof/building tiles +/// 6. Event grid (packed u32 per tile, read from end of file) +/// 7. Tile & access grid (GTL index + access bits per tile) +/// 8. Access-ref grid (BTL overlay ref + shadow level per tile) /// /// The parser handles the isometric coordinate system and converts tile coordinates /// to the internal (x,y) format used throughout the codebase. @@ -359,8 +377,8 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { let tiled_map_width = map_model.tiled_map_width; let tiled_map_height = map_model.tiled_map_height; - first_block(reader)?; - second_block(reader)?; + skip_tiled_object_refs(reader)?; + skip_overlay_id_table(reader)?; let internal_sprites = sprite_block(reader)?; let sprite_blocks = sprite_info_block(reader, &internal_sprites)?; @@ -389,18 +407,22 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { read_tiles_and_access_block(reader, tiled_map_width, tiled_map_height)?; let mut btl_tiles = HashMap::new(); + let mut access_ref_words = HashMap::new(); let current_pos = reader.stream_position()?; let remaining_bytes = file_len - current_pos; let expected_roof_size = (tiled_map_width * tiled_map_height * 4) as u64; - // Only read roof tiles if we have exactly the expected amount of data remaining + // Only read the access-ref grid if we have exactly the expected amount of + // data remaining if remaining_bytes >= expected_roof_size { - btl_tiles = read_roof_tiles(reader, tiled_map_width, tiled_map_height)?; + let (refs, words) = read_roof_tiles(reader, tiled_map_width, tiled_map_height)?; + btl_tiles = refs; + access_ref_words = words; } else if remaining_bytes > 0 { - // If there are remaining bytes but not enough for a full roof block, + // If there are remaining bytes but not enough for a full grid, // this might indicate a different file structure or corruption eprintln!( - "Warning: Found {} bytes after tile blocks, expected {} for roof tiles. Skipping.", + "Warning: Found {} bytes after tile blocks, expected {} for the access-ref grid. Skipping.", remaining_bytes, expected_roof_size ); } @@ -409,6 +431,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { model: map_model, gtl_tiles, btl_tiles, + access_ref_words, collisions, events, object_ids, @@ -448,13 +471,13 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { /// # Map File Structure /// The .MAP file format consists of these main blocks: /// 1. Header: Map dimensions in chunks (25-tile units) -/// 2. Unknown blocks: Skipped during processing +/// 2. Object-ref records + overlay-id table: skipped during processing /// 3. Sprite block: Embedded sprite sequences and metadata -/// 4. Sprite info: Position data for placed sprites +/// 4. Sprite info: Frame bounding boxes for placed sprites /// 5. Tiled objects: Building definitions using stacked tiles -/// 6. Event block: Per-tile event trigger IDs -/// 7. Tile & access: Ground tiles with collision flags -/// 8. Roof tiles: Optional building/roof tile layer +/// 6. Event grid: Packed u32 per tile (event id + flags) +/// 7. Tile & access: Packed u32 per tile (GTL index + access bits) +/// 8. Access-ref grid: BTL overlay refs and shadow levels per tile /// /// Coordinates use an isometric system where each tile is 32×32 pixels, /// with special offsets for proper isometric rendering. @@ -683,7 +706,7 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { let gtl_id = gtl_tiles.get(&coords).cloned().unwrap_or(0); let btl_id = btl_tiles.get(&coords).cloned().unwrap_or(0); let collision = collisions.get(&coords).cloned().unwrap_or(false); - let event_id = events.get(&coords).map(|e| e.event_id).unwrap_or(0); + let event_id = events.get(&coords).map(|e| e.event_id()).unwrap_or(0); if gtl_id == 0 && btl_id == 0 && !collision && event_id == 0 { continue; diff --git a/src/map/reader.rs b/src/map/reader.rs index 35bda4a0..2e5862e0 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -24,24 +24,33 @@ use crate::sprite::SequenceInfo; use super::types::{Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo}; // -------------------------------------------------------------------------- -// Unknown blocks (skipped on read, not persisted) +// Skipped blocks (parsed by the format but not persisted in MapData) // -------------------------------------------------------------------------- -/// First block: `count` (i32) followed by `(count - 1)` records of 2 × i32 each. +/// Tiled-object ref records: `count` (i32) followed by `(count - 1)` records +/// of 2 × i32 each (`value1`, `value2`). +/// +/// `value2` is a linear tile index (`y * stride + x`) into the three end +/// grids — tiled-object rendering and access checks resolve tiles +/// through it. /// /// Verified against cat1/cat3/dun01/map1/catp fixtures: skipping `(count-1)*8` -/// lands exactly on the second block's size field (974, 3783, 1419, 6643, 353), -/// while `count*8` lands on `0x01010101` garbage. The previous -/// `multiplier*size*4` skip only worked by coincidence -/// (`8 + 2*count*4 == 16 + (count-1)*8`). -pub fn first_block(reader: &mut BufReader) -> Result<()> { +/// lands exactly on the overlay table's size field, while `count*8` lands on +/// `0x01010101` garbage. The previous `multiplier*size*4` skip only worked by +/// coincidence (`8 + 2*count*4 == 16 + (count-1)*8`). +pub fn skip_tiled_object_refs(reader: &mut BufReader) -> Result<()> { let count = reader.read_i32::()?; let skip: i64 = ((count - 1) * 8).into(); reader.seek(SeekFrom::Current(skip))?; Ok(()) } -pub fn second_block(reader: &mut BufReader) -> Result<()> { +/// Overlay-id lookup table: `size` (i32) followed by `size` u16 entries. +/// +/// Indexed by the Access-Ref grid's low 15 bits. Each entry packs +/// `{low byte: transparency mode, high byte: draw-enable}` for the BTL +/// overlay tile with that id. +pub fn skip_overlay_id_table(reader: &mut BufReader) -> Result<()> { let size = reader.read_i32::()?; let skip: i64 = (size * 2).into(); reader.seek(SeekFrom::Current(skip))?; @@ -94,12 +103,17 @@ pub fn sprite_info_block( for _ in 0..count { let sprite_id = reader.read_i32::()?; - reader.read_i32::()?; // unknown - reader.read_i32::()?; // unknown - let _sprite_bottom_right_x = reader.read_i32::()?; + // Frame-0 record (24 bytes): bbox {left, top, right, bottom} in map + // pixels + duplicated anchor. left/top are re-read as x/y below; + // right is unused (== left + frame width). + let _bbox_left = reader.read_i32::()?; + let _bbox_top = reader.read_i32::()?; + let _bbox_right = reader.read_i32::()?; let sprite_bottom_right_y = reader.read_i32::()?; let sprite_x = reader.read_i32::()?; let sprite_y = reader.read_i32::()?; + // In known maps left==sprite_x and top==sprite_y; they are stored + // in separate per-frame arrays so they may diverge in mods. let sprite_id: usize = sprite_id.try_into().unwrap_or(0); let skip = (sprites[sprite_id].frame_count - 1) * 6 * 4; @@ -116,44 +130,63 @@ pub fn sprite_info_block( } // -------------------------------------------------------------------------- -// Tiled objects block – buildings/objects composed of BTL tile stacks +// Tiled objects block – building definitions composed of BTL tile stacks +// +// Each bundle is a building placed on the map. The file parser reads, per +// bundle: a sub-record count, then per sub-record {i32 stamp, 260-byte name/ +// metadata block, item count, items}; each item carries seven i32 fields plus +// a u16 array (tile-stack ids) and an optional byte array when the item's +// first i32 == 1. The Rust reader below flattens this byte stream with an +// empirically fitted layout that round-trips all known maps. // -------------------------------------------------------------------------- pub fn tiled_objects_block(reader: &mut BufReader) -> Result> { let bundles_count = reader.read_i32::()?; - let _number1 = reader.read_i32::()?; + // Sub-record count inside the bundle: sub-records of {stamp, 260-byte + // metadata, items} follow at this point. + let _sub_record_count = reader.read_i32::()?; let mut infos: Vec = Vec::with_capacity(bundles_count.unsigned_abs() as usize); for _ in 0..bundles_count { + // 264 bytes: stamp i32 + metadata block (building name/definition). reader.seek(SeekFrom::Current(264))?; - let _s8 = reader.read_i32::()?; - let _s0_1 = reader.read_i32::()?; - let _s1 = reader.read_i32::()?; - let _s0_2 = reader.read_i32::()?; + // Control words — always observed as (8, 0, 1, 0) in known maps. + let _control_0 = reader.read_i32::()?; + let _control_1 = reader.read_i32::()?; + let _control_2 = reader.read_i32::()?; + let _control_3 = reader.read_i32::()?; - let _v1 = reader.read_i32::()?; - let _v2 = reader.read_i32::()?; - let _v3 = reader.read_i32::()?; - let _v4 = reader.read_i32::()?; + // Unmapped parameters preceding the stack anchor. + let _param_0 = reader.read_i32::()?; + let _param_1 = reader.read_i32::()?; + let _param_2 = reader.read_i32::()?; + let _param_3 = reader.read_i32::()?; + + // Anchor of the building stack in map-local pixel coordinates. let x = reader.read_i32::()?; let y = reader.read_i32::()?; - let _v7 = reader.read_i32::()?; - let _v8 = reader.read_i32::()?; - let c1 = reader.read_i32::()?; - let c2 = reader.read_i32::()?; - let c3 = reader.read_i32::()?; + // Unmapped parameters following the anchor. + let _param_4 = reader.read_i32::()?; + let _param_5 = reader.read_i32::()?; + + // Trailing counts. Only `tile_stack_len` BTL tile ids follow here; + // the other two counts gate data covered by the skip below. + let extra_count_a = reader.read_i32::()?; + let extra_count_b = reader.read_i32::()?; + let tile_stack_len = reader.read_i32::()?; + // BTL tile stack for this building, top → bottom. let mut ids: Vec = vec![]; - for _ in 0..c3 { + for _ in 0..tile_stack_len { ids.push(reader.read_i16::()?); } infos.push(TiledObjectInfo { ids, x, y }); reader.seek(SeekFrom::Current(84))?; - let skip: i64 = ((c1 + c2 + c3) * 4).into(); + let skip: i64 = ((extra_count_a + extra_count_b + tile_stack_len) * 4).into(); reader.seek(SeekFrom::Current(skip))?; } @@ -179,9 +212,10 @@ pub fn tiled_objects_block(reader: &mut BufReader) -> Result()?; - let unknown_value = reader.read_i16::()?; - blocks.insert( - (x, y), - EventBlock { - x, - y, - event_id, - _unknown_value: unknown_value, - }, - ); + // One packed u32 per tile; see [`EventBlock`] for the bit layout. + let word = reader.read_u32::()?; + blocks.insert((x, y), EventBlock { x, y, word }); } } Ok(blocks) @@ -212,18 +238,18 @@ pub fn read_events_block( // -------------------------------------------------------------------------- // Tile & access block – one packed u32 per tile // -// Bit layout (verified against Dispel_145_wm.exe): +// Bit layout (verified against known game data): // bits 0 – blocked/collision flag // bits 1–9 – map-object slot id (0–511); non-zero marks an // interactive object dispatched to a subsystem handler // bits 10–24 – GTL ground-tile index: (word >> 10) & 0x7FFF. // The renderer blits from gtl_base + index * 0x800 // (2048 B = one 32×32 RGB565 tile in the .gtl file). -// bits 25–31 – unused (always 0 in shipped maps) +// bits 25–31 – unused (always 0 in known maps) // -// The game's `setaccess` script command rewrites exactly bits 0–9, -// preserving the tile index — the game calls this whole field "access". -// Shipped maps only use bit 0; bits 1–9 are set at runtime. +// Access-modifying script commands rewrite exactly bits 0–9, +// preserving the tile index — this whole field is called "access". +// Known maps only use bit 0; bits 1–9 are set at runtime. // -------------------------------------------------------------------------- /// Parsed result of `read_tiles_and_access_block`: (gtl_tiles, collisions, object_ids). @@ -260,37 +286,48 @@ pub fn read_tiles_and_access_block( } // -------------------------------------------------------------------------- -// Access-ref block ("roof") – one packed u32 per tile +// Access-ref grid ("roof") – one packed u32 per tile // -// Bits 0–14 index the second block's u16 table (the table skipped by -// `second_block`); the entry's low byte is a boolean consumed by the game's -// tile-access/occlusion checks (HIDEACCESS / SHOWACCESS debug commands). -// Bits 15+ are flags (rare — e.g. 7 border tiles in cat1.map). Roof visuals -// themselves come from the tiled-objects block, not from this grid. +// bits 0–14 BTL overlay ref → indexes the u16 table read by +// `skip_overlay_id_table` ({lo byte: transparency mode, +// hi byte: draw-enable}); pixels at btl_base + id * 0x800 +// bit 15 bleeds into the signed-i16 view of the ref (negative ⇒ skip) +// bits 15–29 shadow/darkness level (0–199), per-pixel fade applied by +// the shadow renderer +// bits 30–31 light-source flags (entities' light raises the level, max wins) // -------------------------------------------------------------------------- +/// Returns `(btl_overlay_refs, raw_words)` — the parsed overlay refs plus the +/// untouched packed words so the writer can preserve shadow/light bits. pub fn read_roof_tiles( reader: &mut BufReader, tiled_map_width: i32, tiled_map_height: i32, -) -> Result> { +) -> Result<(HashMap, HashMap)> { let mut btl_tiles = HashMap::new(); + let mut access_ref_words = HashMap::new(); for y in 0..tiled_map_height { for x in 0..tiled_map_width { - let btl_tile_id = reader.read_i16::()?; - let some_flag = reader.read_i16::()?; + let word = reader.read_u32::()?; let coords: Coords = (x, y); + access_ref_words.insert(coords, word); + // Signed-i16 view of the ref, matching the original parser: a ref + // with bit 15 set reads as negative and is skipped. + let btl_tile_id = (word & 0xFFFF) as u16 as i16; + let shadow_and_flags = (word >> 16) as u16 as i16; if btl_tile_id > 0 { - if some_flag > 0 { - println!("ReadRoofTiles TODO: {btl_tile_id:?} {some_flag}"); + if shadow_and_flags != 0 { + println!( + "ReadRoofTiles: overlay tile {btl_tile_id} with shadow/flags {shadow_and_flags:#06x}" + ); } - btl_tiles.insert(coords, btl_tile_id.into()); + btl_tiles.insert(coords, i32::from(btl_tile_id)); } } } - Ok(btl_tiles) + Ok((btl_tiles, access_ref_words)) } #[cfg(test)] @@ -322,7 +359,7 @@ mod tests { #[test] fn test_tile_access_round_trip_zero_object() { - // Shipped file case: object_id=0, high bits 0 + // Known-file case: object_id=0, high bits 0 let gtl_tile_id: i32 = 42; let object_id: i32 = 0; let blocked = false; diff --git a/src/map/render.rs b/src/map/render.rs index a3a6b29d..da9f2484 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -594,7 +594,7 @@ pub fn plot_roofs( // -------------------------------------------------------------------------- /// Loads all external entity data (monsters, NPCs, extras, draw items) for the -/// given map from the game files. +/// given map from the game data files. pub fn collect_external_entities( map_id: &str, game_path: &Path, @@ -1132,13 +1132,11 @@ fn plot_events_overlay( for x in start_x..=end_x { let y = x + diff; let coords: Coords = (x, y); - let event = events.get(&coords).copied().unwrap_or(EventBlock { - x, - y, - _unknown_value: 0, - event_id: 0, - }); - if event.event_id == 0 { + let event = events + .get(&coords) + .copied() + .unwrap_or(EventBlock { x, y, word: 0 }); + if event.event_id() == 0 { continue; } let (mut px, mut py) = convert_map_coords_to_image_coords(x, y, diagonal); @@ -1155,7 +1153,7 @@ fn plot_events_overlay( imgbuf, cx, cy - 8, - event.event_id as i32, + event.event_id() as i32, [255, 255, 255], 3, ); diff --git a/src/map/tmx.rs b/src/map/tmx.rs index 6c0957f3..ceeff514 100644 --- a/src/map/tmx.rs +++ b/src/map/tmx.rs @@ -111,7 +111,7 @@ fn write_tileset_atlas(tiles: &[Tile], path: &Path) -> std::io::Result<()> { /// * BTL firstgid = `gtl_tile_count + 1` /// * Empty / no tile → GID 0 /// -/// Tile index 0 in the game format means "no tile" (empty). +/// Tile index 0 in the map format means "no tile" (empty). pub fn export_tmx( map_data: &MapData, gtl_path: &Path, @@ -208,7 +208,7 @@ pub fn export_tmx( for y in 0..map_h { for x in 0..map_w { - // tile_id == 0 in the game format means "no tile" → GID 0. + // tile_id == 0 in the map format means "no tile" → GID 0. let gid = match map_data.gtl_tiles.get(&(x, y)) { Some(&id) if id > 0 => gtl_firstgid as i32 + id, _ => 0, @@ -275,7 +275,7 @@ pub fn export_tmx( for y in 0..map_h { for x in 0..map_w { if let Some(event) = map_data.events.get(&(x, y)) - && event.event_id != 0 + && event.event_id() != 0 { let px = (x - y) * (TILE_WIDTH as i32 / 2); let py = (x + y) * (TILE_HEIGHT as i32 / 2); @@ -287,7 +287,12 @@ pub fn export_tmx( "#, - obj_id, px, py, TILE_WIDTH, TILE_HEIGHT, event.event_id + obj_id, + px, + py, + TILE_WIDTH, + TILE_HEIGHT, + event.event_id() )?; obj_id += 1; } diff --git a/src/map/types.rs b/src/map/types.rs index 0b77e030..77adc58e 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -37,21 +37,61 @@ pub fn internal_sprite_sort_key(bottom_right_y: i32) -> i32 { } /// An event trigger attached to a tile on the map. +/// +/// Stored in the file as one packed u32 (the first of the three end grids). +/// Decode it through the accessors instead of masking by hand: +/// +/// ```text +/// bits 0-13 event/transition id (low 14 bits; ids < 70 are +/// valid and resolve to a name via Map.ini / AllMap.ini) +/// bits 14-21 unmapped +/// bit 22 "tile marked / entity occupies" — monster chase logic +/// treats marked tiles as blocked +/// bits 23-31 unmapped parameters +/// ``` #[derive(Copy, Clone, Debug, Serialize, Deserialize)] pub struct EventBlock { pub x: i32, pub y: i32, - pub _unknown_value: i16, - pub event_id: i16, + /// The tile's packed u32 exactly as stored in the file. + pub word: u32, +} + +impl EventBlock { + /// Event/transition id — bits 0–13 of [`Self::word`]. + pub fn event_id(&self) -> u16 { + (self.word & 0x3FFF) as u16 + } + + /// Overwrite the event id (bits 0–13), preserving every other bit. + pub fn set_event_id(&mut self, id: u16) { + self.word = (self.word & !0x3FFF) | (u32::from(id) & 0x3FFF); + } + + /// Word bit 22 (`0x0040_0000`): the tile is marked/occupied by an entity. + /// Movement code makes chasing monsters give up when their + /// target stands on such a tile. + pub fn is_tile_marked(&self) -> bool { + self.word & 0x0040_0000 != 0 + } } /// Placement record for a sprite embedded directly in the map file. +/// +/// On-disk this is `[sprite_id] + frame-0 record`; the frame record is +/// 24 bytes = bounding box `{left, top, right, bottom}` in *map pixel +/// coordinates* followed by a duplicated `{x, y}` anchor. Verified against +/// fixtures: `right - left == frame0.width` and `bottom - top == +/// frame0.height` for every placement in cat1/map1. #[derive(Copy, Clone, Debug, Serialize, Deserialize)] pub struct SpriteInfoBlock { pub sprite_id: usize, + /// Left edge of the sprite's bounding box in map pixels (duplicated on disk). pub sprite_x: i32, + /// Top edge of the sprite's bounding box in map pixels (duplicated on disk). pub sprite_y: i32, - /// Bottom-right Y pixel (occluded space) — used as the Y-sort key for interlaced rendering. + /// Bottom edge of the sprite's bounding box (= top + frame height). + /// Y-sort key for interlaced rendering. pub sprite_bottom_right_y: i32, } @@ -61,4 +101,4 @@ pub struct TiledObjectInfo { pub ids: Vec, pub x: i32, pub y: i32, -} \ No newline at end of file +} diff --git a/src/map/writer.rs b/src/map/writer.rs index 01ab2679..0c5647e2 100644 --- a/src/map/writer.rs +++ b/src/map/writer.rs @@ -5,9 +5,9 @@ use std::path::Path; use super::MapData; use super::types::Coords; -/// Write map data back to a .map file by patching only the 3 end blocks -/// (events, tiles+collisions, roofs) in-place. All header/sprite/object -/// bytes are preserved unchanged. +/// Write map data back to a .map file by patching only the 3 end grids +/// (event grid, tile & access grid, access-ref grid) in-place. All +/// header/sprite/object bytes are preserved unchanged. pub fn write_map_to_path(path: &Path, data: &MapData) -> io::Result<()> { let w = data.model.tiled_map_width; let h = data.model.tiled_map_height; @@ -34,13 +34,8 @@ pub fn write_map_to_path(path: &Path, data: &MapData) -> io::Result<()> { let coords: Coords = (x, y); let base = (events_offset + (y as u64 * w as u64 + x as u64) * 4) as usize; - let event = data - .events - .get(&coords) - .map(|e| (e.event_id, e._unknown_value)) - .unwrap_or((0, 0)); - bytes[base..base + 2].copy_from_slice(&(event.0 as i16).to_le_bytes()); - bytes[base + 2..base + 4].copy_from_slice(&(event.1 as i16).to_le_bytes()); + let word = data.events.get(&coords).map(|e| e.word).unwrap_or(0); + bytes[base..base + 4].copy_from_slice(&word.to_le_bytes()); } } @@ -60,17 +55,20 @@ pub fn write_map_to_path(path: &Path, data: &MapData) -> io::Result<()> { } } - // Roof block offset - let roof_offset = file_len - block_size; + // Access-ref ("roof") block offset — write the raw packed words back + // (preserving shadow levels and light flags), patching only the overlay + // ref bits from any btl_tiles edits. + let access_ref_offset = file_len - block_size; for y in 0..h { for x in 0..w { let coords: Coords = (x, y); - let base = (roof_offset + (y as u64 * w as u64 + x as u64) * 4) as usize; + let base = (access_ref_offset + (y as u64 * w as u64 + x as u64) * 4) as usize; - let btl_id = data.btl_tiles.get(&coords).copied().unwrap_or(0) as i16; - let some_flag: i16 = 0; // preserve zero — reader doesn't parse this meaningfully - bytes[base..base + 2].copy_from_slice(&btl_id.to_le_bytes()); - bytes[base + 2..base + 4].copy_from_slice(&some_flag.to_le_bytes()); + let mut word = data.access_ref_words.get(&coords).copied().unwrap_or(0); + if let Some(&id) = data.btl_tiles.get(&coords) { + word = (word & !0x7FFF) | ((id as u32) & 0x7FFF); + } + bytes[base..base + 4].copy_from_slice(&word.to_le_bytes()); } } @@ -122,6 +120,10 @@ mod tests { "Collisions differ" ); assert_eq!(original.btl_tiles, reloaded.btl_tiles, "BTL tiles differ"); + assert_eq!( + original.access_ref_words, reloaded.access_ref_words, + "Access-ref words (shadow/light bits) differ" + ); assert_eq!( original.object_ids, reloaded.object_ids, "Object IDs differ" @@ -132,11 +134,7 @@ mod tests { let r_ev = reloaded.events.get(&(x, y)); match (o_ev, r_ev) { (Some(a), Some(b)) => { - assert_eq!(a.event_id, b.event_id, "event_id mismatch at ({x},{y})"); - assert_eq!( - a._unknown_value, b._unknown_value, - "unknown_value mismatch at ({x},{y})" - ); + assert_eq!(a.word, b.word, "event word mismatch at ({x},{y})"); } (None, None) => {} _ => panic!("Event presence differs at ({x},{y})"), @@ -162,14 +160,14 @@ mod tests { // Check we can change an event_id let mut modified2 = reloaded2; if let Some(ev) = modified2.events.get_mut(&(5, 5)) { - ev.event_id = 42; + ev.set_event_id(42); } write_map_to_path(&tmp, &modified2).unwrap(); let file = fs::File::open(&tmp).unwrap(); let mut reader = BufReader::new(file); let reloaded3 = read_map_data(&mut reader).unwrap(); assert_eq!( - reloaded3.events.get(&(5, 5)).map(|e| e.event_id), + reloaded3.events.get(&(5, 5)).map(|e| e.event_id()), Some(42), "Modified event_id not reflected" ); @@ -193,6 +191,7 @@ mod tests { }, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), + access_ref_words: HashMap::new(), collisions: HashMap::new(), events: HashMap::new(), object_ids: HashMap::new(), From df3bf3de09945e74a10bb0dfa8f82f9eec8a4e14 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:33:33 +0200 Subject: [PATCH 11/49] Cleanup wording --- src/references/all_map_ini.rs | 2 +- src/references/edit_item_db.rs | 10 +++++----- src/references/extra_ini.rs | 4 ++-- src/references/magic_db.rs | 12 ++++++------ src/references/party_ini_db.rs | 10 +++++----- src/references/save_file/events.rs | 12 +++++------- src/references/save_file/party_members.rs | 2 +- src/references/save_ifo.rs | 2 +- src/references/weapons_db.rs | 12 ++++++------ 9 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/references/all_map_ini.rs b/src/references/all_map_ini.rs index efe437f6..333e4d32 100644 --- a/src/references/all_map_ini.rs +++ b/src/references/all_map_ini.rs @@ -36,7 +36,7 @@ use dispel_macros::{Localizable, TextExtractor, TextRecordPatcher}; /// # File Purpose /// /// Master index of all game maps, linking map IDs to filenames and metadata. -/// Used by the game engine to load the correct map files and associated assets. +/// Used to resolve the correct map files and associated assets. #[derive( Debug, Clone, Serialize, Deserialize, Default, TextExtractor, Localizable, TextRecordPatcher, )] diff --git a/src/references/edit_item_db.rs b/src/references/edit_item_db.rs index a480ee1f..9b42626b 100644 --- a/src/references/edit_item_db.rs +++ b/src/references/edit_item_db.rs @@ -72,9 +72,9 @@ use dispel_macros::RecordPatcher; /// /// - `modifies_item`: Enum controlling if item mutates behavior /// - `additional_effect`: Enum for procedural elemental modifiers (mana drain, fire, etc.) -/// - `runtime_item_id`: Stored as zero in the shipped database; overwritten with the zero-based -/// record index by the game loader and used to identify this item definition at runtime. -/// - `reserved_byte`: Always zero in shipped data; no runtime access was identified. +/// - `runtime_item_id`: Stored as zero in known files; overwritten with the zero-based +/// record index at load time and used to identify this item definition. +/// - `reserved_byte`: Always zero by default; no reader accesses it. /// /// # File Purpose /// @@ -101,7 +101,7 @@ pub struct EditItem { /// Economic valuation offset. #[extractor(primitive(type = "i32"))] pub base_price: i32, - /// Runtime database index assigned by the game loader. + /// Runtime database index assigned at load time. /// /// The on-disk database leaves this word as zero. The game overwrites it after loading and /// uses it to identify this definition in inventory and equipment records. @@ -144,7 +144,7 @@ pub struct EditItem { /// Resistance to modification by edit items. Negative values make an item easier to modify. #[extractor(primitive(type = "i16"))] pub modification_resistance: i16, - /// Reserved byte. Always zero in shipped data; no runtime use was identified. + /// Reserved byte. Always zero by default; purpose unknown. #[extractor(primitive(type = "u8"))] pub reserved_byte: u8, /// Flag specifying if behavior mutates. diff --git a/src/references/extra_ini.rs b/src/references/extra_ini.rs index 3ae4eb0e..adc85cb9 100644 --- a/src/references/extra_ini.rs +++ b/src/references/extra_ini.rs @@ -55,10 +55,10 @@ pub struct Extra { pub sprite_filename: Option, /// Selects the sprite frame used after the object is activated. /// - /// The game copies this value into every placement using this definition. + /// Every placement using this definition carries a copy of this value. /// For object interaction handlers 5, 6, and 8, a value greater than `1` /// selects activated sprite frame `1`; `0` and `1` select frame `0` on - /// that path. Shipped definitions use `0`, `1`, and `2`, so this is not a + /// that path. Observed definitions use `0`, `1`, and `2`, so this is not a /// boolean or a quest flag. #[extractor(field = 2)] pub activation_sprite_frame_mode: i32, diff --git a/src/references/magic_db.rs b/src/references/magic_db.rs index b9ef1250..8c313662 100644 --- a/src/references/magic_db.rs +++ b/src/references/magic_db.rs @@ -56,9 +56,9 @@ use serde::{Deserialize, Serialize}; /// +--------------------------------------+ /// ``` /// -/// # Reverse-engineered behavior +/// # Observed behavior /// -/// The combat code reads `base_damage`, `base_success_rate`, `mana_cost`, +/// Combat calculations consume `base_damage`, `base_success_rate`, `mana_cost`, /// `range`, `cast_duration`, `magic_type`, `animation_set_id`, and /// `effect_visual_id`. Effective mana cost is reduced by the caster's /// magic-school skill, with a minimum of 5; effective success chance also @@ -99,11 +99,11 @@ pub struct MagicSpell { #[extractor(primitive(type = "u32"))] pub mana_cost: u32, - /// Reserved word at record offset `0x14`; zero in the shipped `Magic.db`. + /// Reserved word at record offset `0x14`; zero by default. #[extractor(primitive(type = "u32"))] pub reserved_0x14: u32, - /// Reserved word at record offset `0x18`; zero in the shipped `Magic.db`. + /// Reserved word at record offset `0x18`; zero by default. #[extractor(primitive(type = "u32"))] pub reserved_0x18: u32, @@ -116,7 +116,7 @@ pub struct MagicSpell { #[extractor(primitive(type = "u32"))] pub range: u32, - /// Reserved word at record offset `0x24`; zero in the shipped `Magic.db`. + /// Reserved word at record offset `0x24`; zero by default. #[extractor(primitive(type = "u32"))] pub reserved_0x24: u32, @@ -142,7 +142,7 @@ pub struct MagicSpell { #[extractor(primitive(type = "u32"))] pub effect_modifier: u32, - /// Reserved word at record offset `0x3c`; zero in the shipped `Magic.db`. + /// Reserved word at record offset `0x3c`; zero by default. #[extractor(primitive(type = "u32"))] pub reserved_0x3c: u32, diff --git a/src/references/party_ini_db.rs b/src/references/party_ini_db.rs index 38fc4a44..fe43331c 100644 --- a/src/references/party_ini_db.rs +++ b/src/references/party_ini_db.rs @@ -49,10 +49,10 @@ use std::path::Path; /// # Special Values /// /// - `name`: 20 bytes max, null-padded (UTF-8) -/// - `class_id`: Shipped values are 21 through 24; this selects class-specific +/// - `class_id`: Observed values are 21 through 24; this selects class-specific /// runtime behavior and titles. -/// - `pathfinding_mode`: Shipped value is 7; passed to map/path queries. -/// - `character_variant`: Shipped values are 0 or 1; selects one of two variants +/// - `pathfinding_mode`: Observed value is 7; passed to map/path queries. +/// - `character_variant`: Observed values are 0 or 1; selects one of two variants /// for each class. /// /// # File Purpose @@ -68,10 +68,10 @@ pub struct PartyIniNpc { #[extractor(string(encoding = "UTF-8", size = 20))] #[translatable(encoding = "WINDOWS_1250", max_bytes = 20)] pub name: String, - /// Reserved byte at offset `0x14`; zero in every shipped record. + /// Reserved byte at offset `0x14`; zero by default. #[extractor(primitive(type = "u8"))] pub reserved_0x14: u8, - /// Character class identifier. Shipped values are 21–24. + /// Character class identifier. Observed values are 21–24. /// TODO: Create an enum, which maps: 21 = Knight, 22 = Mage, 23 = Mage, 24 = Warrior. #[extractor(primitive(type = "u8"))] pub class_id: u8, diff --git a/src/references/save_file/events.rs b/src/references/save_file/events.rs index 9672150c..a45d3ada 100644 --- a/src/references/save_file/events.rs +++ b/src/references/save_file/events.rs @@ -159,15 +159,14 @@ impl PostEventsData { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, BinaryRecord)] pub struct WalkMilestoneRecord { /// Event id. `400`; `10`/`100`/`200`/`300` when the - /// walk-freshness counter is active; ascending global counter in shipped - /// saves (53, 66, 73, ...). + /// walk-freshness counter is active; ascending global counter across saves. pub id: u32, /// Walk direction (animation-step direction, 0-7). pub direction: u32, /// Character movement state (`0`=idle, `1`=walking). pub state: u32, /// Walk-type flag. `0` (which duplicates `direction` - /// into this slot); `1` in shipped saves. + /// into this slot); observed as `1`. pub walk_type: u32, /// X coordinate. pub x: u32, @@ -180,15 +179,14 @@ pub struct WalkMilestoneRecord { /// The record is created when the active path reaches its completion point. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, BinaryRecord)] pub struct WalkCompletionRecord { - /// Event id. `2000`; ascending global counter in - /// shipped saves (684, 775, 828, ...). + /// Event id. `2000`; ascending global counter across saves. pub id: u32, /// Normalized walk direction (0-3; walk directions 4-7 map to 0-3). pub direction: u32, /// Diagonal flag: `1` when the walk direction is diagonal. pub diagonal: u32, - /// Character index (`0` for party members; `0`-`2` in - /// shipped saves). + /// Character index (`0` for party members; `0`-`2` + /// observed). pub character_index: u32, /// X coordinate. pub x: u32, diff --git a/src/references/save_file/party_members.rs b/src/references/save_file/party_members.rs index 21c17744..a9fad406 100644 --- a/src/references/save_file/party_members.rs +++ b/src/references/save_file/party_members.rs @@ -22,7 +22,7 @@ pub struct PartyMemberBinaryRecord { /// Duplicate maximum-mana bytes from the next overlapping four-byte snapshot. #[binary_record(size = 2)] pub maximum_mana_points_overlap: Vec, - /// Party character class ID from `PrtIni.db` (21–24 in shipped data). + /// Party character class ID from `PrtIni.db` (observed values 21–24). pub class_id: u8, /// Current progression level. pub level: u8, diff --git a/src/references/save_ifo.rs b/src/references/save_ifo.rs index 0237c61b..3d3cc5f6 100644 --- a/src/references/save_ifo.rs +++ b/src/references/save_ifo.rs @@ -17,7 +17,7 @@ pub const SLOT_COUNT: usize = 6; pub struct SaveIfo { /// Per-slot metadata; always exactly [`SLOT_COUNT`] entries. pub slots: Vec, - /// Unknown float; the game writes the constant 1.4 before every save. + /// Unknown float; written as constant 1.4 before every save. pub unknown_float: f32, /// Key of this session's payload inside the `game.tmp` append-log. pub game_tmp_key: u32, diff --git a/src/references/weapons_db.rs b/src/references/weapons_db.rs index c5239ede..1643c671 100644 --- a/src/references/weapons_db.rs +++ b/src/references/weapons_db.rs @@ -69,10 +69,10 @@ use serde::{Deserialize, Serialize}; /// - **Durability**: `durability` (item HP/wear limit) /// - **Requirements**: `req_strength`, `req_agility`, `req_wisdom` (minimum stats to equip) /// - **Runtime identity**: `weapon_item_id` is overwritten with the zero-based -/// record index by the game loader. +/// record index at load time. /// - **Reserved**: the seven `reserved_0x*` words are serialized in the file and -/// copied into inventory items, but are zero in every shipped record and have -/// no observed runtime use. +/// copied into inventory items, but are zero by default and have +/// no observed reader. /// /// # Special Values /// @@ -80,7 +80,7 @@ use serde::{Deserialize, Serialize}; /// - `durability`: Item health points before destruction /// - `weapon_item_id`: Runtime-only ID; the on-disk value is ignored and replaced /// with the record index when the database loads. -/// - `reserved_0x*`: Preserved 16-bit values. All are zero in the shipped database. +/// - `reserved_0x*`: Preserved 16-bit values. All are zero by default. /// - Requirements: 0 = no requirement /// /// # File Purpose @@ -149,8 +149,8 @@ pub struct WeaponItem { /// Item health points or wear limit. #[extractor(primitive(type = "i16"))] pub durability: i16, - /// Reserved serialized word at byte offset `0x108`. It is zero in all - /// shipped records and has no observed runtime use. + /// Reserved serialized word at byte offset `0x108`. It is zero by default + /// with no observed reader. #[extractor(padding(count = 1, type = "i16"))] pub reserved_0x108: i16, /// Reserved serialized word at byte offset `0x10a`. From 442215d3a277da3ba27194a5e6dbf1459bb0dc34 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:36:31 +0200 Subject: [PATCH 12/49] Get rid of the foreign keys in the SQLite logic --- src/queries/create_table_dialogue_paragraphs.sql | 4 ++-- src/queries/create_table_dialogue_scripts.sql | 11 +++-------- src/queries/create_table_draw_items.sql | 2 +- src/queries/create_table_event_npc_refs.sql | 2 +- src/queries/create_table_events.sql | 2 +- src/queries/create_table_extra_refs.sql | 8 ++++---- src/queries/create_table_map_inis.sql | 4 ++-- src/queries/create_table_monster_refs.sql | 4 ++-- src/queries/create_table_monsters.sql | 6 +++--- src/queries/create_table_npc_refs.sql | 11 +++++------ src/queries/create_table_party_refs.sql | 10 ++++------ src/queries/create_table_sprite_frames.sql | 2 +- src/queries/create_table_sprite_sequences.sql | 2 +- src/queries/create_table_store_products.sql | 2 +- 14 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/queries/create_table_dialogue_paragraphs.sql b/src/queries/create_table_dialogue_paragraphs.sql index 482c9c6f..a8346684 100644 --- a/src/queries/create_table_dialogue_paragraphs.sql +++ b/src/queries/create_table_dialogue_paragraphs.sql @@ -1,10 +1,10 @@ CREATE TABLE IF NOT EXISTS dialogue_paragraphs ( - file_id INTEGER NOT NULL REFERENCES dialogue_script_files(id), + file_id INTEGER NOT NULL, id INTEGER, text TEXT, comment TEXT, param1 INTEGER, - wave_ini_entry_id INTEGER REFERENCES wave_inis(id) ON DELETE SET NULL, + wave_ini_entry_id INTEGER, PRIMARY KEY (file_id, id) ) diff --git a/src/queries/create_table_dialogue_scripts.sql b/src/queries/create_table_dialogue_scripts.sql index 299b7516..a59844e6 100644 --- a/src/queries/create_table_dialogue_scripts.sql +++ b/src/queries/create_table_dialogue_scripts.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS dialogue_scripts( - dialog_file_id INTEGER NOT NULL REFERENCES dialogue_script_files(id), + dialog_file_id INTEGER NOT NULL, id INTEGER NOT NULL, required_event_id INTEGER, next_dialog_to_check INTEGER, @@ -9,11 +9,6 @@ CREATE TABLE IF NOT EXISTS dialogue_scripts( next_dialog_id1 INTEGER, next_dialog_id2 INTEGER, next_dialog_id3 INTEGER, - triggered_event_id INTEGER REFERENCES events(event_id), - PRIMARY KEY (dialog_file_id, id), - FOREIGN KEY (required_event_id) REFERENCES events(event_id), - FOREIGN KEY (dialog_file_id, dialog_id) REFERENCES dialogue_paragraphs(file_id, id), - FOREIGN KEY (dialog_file_id, next_dialog_id1) REFERENCES dialogue_scripts(dialog_file_id, id), - FOREIGN KEY (dialog_file_id, next_dialog_id2) REFERENCES dialogue_scripts(dialog_file_id, id), - FOREIGN KEY (dialog_file_id, next_dialog_id3) REFERENCES dialogue_scripts(dialog_file_id, id) + triggered_event_id INTEGER, + PRIMARY KEY (dialog_file_id, id) ) diff --git a/src/queries/create_table_draw_items.sql b/src/queries/create_table_draw_items.sql index d97e4471..5bae0397 100644 --- a/src/queries/create_table_draw_items.sql +++ b/src/queries/create_table_draw_items.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS draw_items ( - map_id INTEGER NOT NULL REFERENCES maps(id) ON DELETE CASCADE, + map_id INTEGER NOT NULL, x_coord INTEGER, y_coord INTEGER, item_id INTEGER, diff --git a/src/queries/create_table_event_npc_refs.sql b/src/queries/create_table_event_npc_refs.sql index af287273..277a5dc2 100644 --- a/src/queries/create_table_event_npc_refs.sql +++ b/src/queries/create_table_event_npc_refs.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS event_npc_refs ( id INTEGER PRIMARY KEY, - event_id INTEGER NOT NULL REFERENCES events(event_id) ON DELETE CASCADE, + event_id INTEGER NOT NULL, name TEXT ); diff --git a/src/queries/create_table_events.sql b/src/queries/create_table_events.sql index 05401f3d..136e2707 100644 --- a/src/queries/create_table_events.sql +++ b/src/queries/create_table_events.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS events ( event_id INTEGER PRIMARY KEY, - required_event_id INTEGER REFERENCES events(event_id) ON DELETE SET NULL, + required_event_id INTEGER, event_type_id INTEGER, event_filename TEXT, counter INTEGER diff --git a/src/queries/create_table_extra_refs.sql b/src/queries/create_table_extra_refs.sql index dce11674..25f34111 100644 --- a/src/queries/create_table_extra_refs.sql +++ b/src/queries/create_table_extra_refs.sql @@ -1,9 +1,9 @@ CREATE TABLE IF NOT EXISTS extra_refs ( - file_id INTEGER NOT NULL REFERENCES extra_ref_files(id), + file_id INTEGER NOT NULL, id INTEGER, number_in_file INTEGER, - ext_id INTEGER REFERENCES extras(id) ON DELETE SET NULL, + ext_id INTEGER, name TEXT, object_type INTEGER, x_pos INTEGER, @@ -33,8 +33,8 @@ CREATE TABLE IF NOT EXISTS extra_refs unknown12 INTEGER, unknown13 INTEGER, unknown14 BLOB, - event_id INTEGER REFERENCES events(event_id) ON DELETE SET NULL, - message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL, + event_id INTEGER, + message_id INTEGER, unknown15 INTEGER, unknown16 INTEGER, unknown17 INTEGER, diff --git a/src/queries/create_table_map_inis.sql b/src/queries/create_table_map_inis.sql index 7ef55ac0..b955f902 100644 --- a/src/queries/create_table_map_inis.sql +++ b/src/queries/create_table_map_inis.sql @@ -1,10 +1,10 @@ CREATE TABLE IF NOT EXISTS map_inis ( id INTEGER, - event_id_on_camera_move INTEGER REFERENCES events(event_id) ON DELETE SET NULL, + event_id_on_camera_move INTEGER, start_pos_x INTEGER, start_pos_y INTEGER, - map_id INTEGER NOT NULL REFERENCES maps(id) ON DELETE CASCADE, + map_id INTEGER NOT NULL, monsters_filename TEXT, npc_filename TEXT, extra_filename TEXT, diff --git a/src/queries/create_table_monster_refs.sql b/src/queries/create_table_monster_refs.sql index bba0a3a4..3b73d5ff 100644 --- a/src/queries/create_table_monster_refs.sql +++ b/src/queries/create_table_monster_refs.sql @@ -1,9 +1,9 @@ CREATE TABLE monster_refs ( - file_id INTEGER NOT NULL REFERENCES monster_ref_files(id), + file_id INTEGER NOT NULL, id INTEGER, placement_id INTEGER, - monster_db_id INTEGER REFERENCES monster_inis(id) ON DELETE SET NULL, + monster_db_id INTEGER, map_x INTEGER, map_y INTEGER, initial_patrol_countdown INTEGER, diff --git a/src/queries/create_table_monsters.sql b/src/queries/create_table_monsters.sql index 1ccfe54a..6b3469df 100644 --- a/src/queries/create_table_monsters.sql +++ b/src/queries/create_table_monsters.sql @@ -26,9 +26,9 @@ CREATE TABLE monsters gold_drop_min INTEGER, detection_sight_size INTEGER, distance_range_size INTEGER, - known_spell_slot1 INTEGER REFERENCES magic_spells(id) ON DELETE SET NULL, - known_spell_slot2 INTEGER REFERENCES magic_spells(id) ON DELETE SET NULL, - known_spell_slot3 INTEGER REFERENCES magic_spells(id) ON DELETE SET NULL, + known_spell_slot1 INTEGER, + known_spell_slot2 INTEGER, + known_spell_slot3 INTEGER, is_oversize INTEGER, magic_level INTEGER, special_attack INTEGER, diff --git a/src/queries/create_table_npc_refs.sql b/src/queries/create_table_npc_refs.sql index b4229aa5..69c4057b 100644 --- a/src/queries/create_table_npc_refs.sql +++ b/src/queries/create_table_npc_refs.sql @@ -1,13 +1,13 @@ CREATE TABLE npc_refs ( - file_id INTEGER NOT NULL REFERENCES npc_ref_files(id), + file_id INTEGER NOT NULL, row_id INTEGER, file_record_id INTEGER, - npc_ini_id INTEGER REFERENCES npc_inis(id) ON DELETE SET NULL, + npc_ini_id INTEGER, name TEXT, role_description TEXT, party_member_slot INTEGER, - show_on_event INTEGER REFERENCES events(event_id) ON DELETE SET NULL, + show_on_event INTEGER, movement_mode INTEGER, goto1_filled INTEGER, goto2_filled INTEGER, @@ -42,8 +42,7 @@ CREATE TABLE npc_refs interaction_result_item_type INTEGER, interaction_result_raw INTEGER, interaction_range_offset INTEGER, - dialog_file_id INTEGER NOT NULL REFERENCES dialogue_script_files(id), + dialog_file_id INTEGER NOT NULL, dialog_id INTEGER, - dialogue_face_sprite_id INTEGER, - FOREIGN KEY (dialog_file_id, dialog_id) REFERENCES dialogue_scripts(dialog_file_id, id) + dialogue_face_sprite_id INTEGER ); diff --git a/src/queries/create_table_party_refs.sql b/src/queries/create_table_party_refs.sql index 198f0f2a..f407628a 100644 --- a/src/queries/create_table_party_refs.sql +++ b/src/queries/create_table_party_refs.sql @@ -3,12 +3,10 @@ CREATE TABLE IF NOT EXISTS party_refs id INTEGER, full_name TEXT, job_name TEXT, - root_map_id INTEGER NOT NULL REFERENCES maps(id) ON DELETE CASCADE, - npc_id INTEGER NOT NULL REFERENCES npc_inis(id) ON DELETE CASCADE, - party_dlg_file_id INTEGER NOT NULL REFERENCES dialogue_script_files(id), + root_map_id INTEGER NOT NULL, + npc_id INTEGER NOT NULL, + party_dlg_file_id INTEGER NOT NULL, dlg_when_not_in_party INTEGER, dlg_when_in_party INTEGER, - ghost_face_id INTEGER, - FOREIGN KEY (party_dlg_file_id, dlg_when_not_in_party) REFERENCES dialogue_scripts(dialog_file_id, id), - FOREIGN KEY (party_dlg_file_id, dlg_when_in_party) REFERENCES dialogue_scripts(dialog_file_id, id) + ghost_face_id INTEGER ) \ No newline at end of file diff --git a/src/queries/create_table_sprite_frames.sql b/src/queries/create_table_sprite_frames.sql index 8eff9da9..f47a44b2 100644 --- a/src/queries/create_table_sprite_frames.sql +++ b/src/queries/create_table_sprite_frames.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS sprite_frames ( - sprite_file_id INTEGER NOT NULL REFERENCES sprite_files(id), + sprite_file_id INTEGER NOT NULL, sequence_index INTEGER NOT NULL, frame_index INTEGER NOT NULL, png_blob BLOB NOT NULL, diff --git a/src/queries/create_table_sprite_sequences.sql b/src/queries/create_table_sprite_sequences.sql index a92a19af..a5016584 100644 --- a/src/queries/create_table_sprite_sequences.sql +++ b/src/queries/create_table_sprite_sequences.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS sprite_sequences ( - sprite_file_id INTEGER NOT NULL REFERENCES sprite_files(id), + sprite_file_id INTEGER NOT NULL, sequence_index INTEGER NOT NULL, frame_count INTEGER NOT NULL, first_frame_width INTEGER NOT NULL, diff --git a/src/queries/create_table_store_products.sql b/src/queries/create_table_store_products.sql index f2254f12..7b8490c3 100644 --- a/src/queries/create_table_store_products.sql +++ b/src/queries/create_table_store_products.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS store_products ( - store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + store_id INTEGER, order_id INTEGER, product_type INTEGER, product_id INTEGER From 72b35a1c4382363ae41cdabd8a96d17e0d7d9b60 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:36:50 +0200 Subject: [PATCH 13/49] Fix the EditItem SQL code --- src/queries/insert_edit_item.sql | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/queries/insert_edit_item.sql b/src/queries/insert_edit_item.sql index c74ce7dd..90ea3bdb 100644 --- a/src/queries/insert_edit_item.sql +++ b/src/queries/insert_edit_item.sql @@ -37,5 +37,4 @@ VALUES (?1, ?17, ?18, ?19, - ?20, - ?21) + ?20) From 2e0b7e7c48182940326e33b6a02faf748dd0791c Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:37:37 +0200 Subject: [PATCH 14/49] Recognise the game version in the docs. --- docs/files/Save.ifo.md | 2 +- src/references/save_ifo.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/files/Save.ifo.md b/docs/files/Save.ifo.md index 3ceacb87..65805a48 100644 --- a/docs/files/Save.ifo.md +++ b/docs/files/Save.ifo.md @@ -31,7 +31,7 @@ from the `game.tmp` append-log. | Off | Type | Meaning | |-----|------|--------------------------------------------------------------------------------------------------| -| 192 | f32 | unknown float — written as constant 1.4 before every save | +| 192 | f32 | game version — written as 1.4 before every save | | 196 | u32 | `game.tmp` journal key identifying the current session's payload | | 200 | u32 | map/world id active at save time | | 204 | u32 | unknown; zero by default | diff --git a/src/references/save_ifo.rs b/src/references/save_ifo.rs index 3d3cc5f6..a37e1bba 100644 --- a/src/references/save_ifo.rs +++ b/src/references/save_ifo.rs @@ -17,8 +17,8 @@ pub const SLOT_COUNT: usize = 6; pub struct SaveIfo { /// Per-slot metadata; always exactly [`SLOT_COUNT`] entries. pub slots: Vec, - /// Unknown float; written as constant 1.4 before every save. - pub unknown_float: f32, + /// Game version; observed value 1.4. + pub game_version: f32, /// Key of this session's payload inside the `game.tmp` append-log. pub game_tmp_key: u32, /// Map/world id that was active when the game was last saved. @@ -84,7 +84,7 @@ impl SaveIfo { }); } - let unknown_float = cursor.read_f32::()?; + let game_version = cursor.read_f32::()?; let game_tmp_key = cursor.read_u32::()?; let map_id = cursor.read_u32::()?; let unknown = cursor.read_u32::()?; @@ -106,7 +106,7 @@ impl SaveIfo { Ok(SaveIfo { slots, - unknown_float, + game_version, game_tmp_key, map_id, unknown, @@ -134,7 +134,7 @@ impl SaveIfo { writer.write_u32::(slot.minute)?; writer.write_all(&slot.flags)?; } - writer.write_f32::(self.unknown_float)?; + writer.write_f32::(self.game_version)?; writer.write_u32::(self.game_tmp_key)?; writer.write_u32::(self.map_id)?; writer.write_u32::(self.unknown)?; From 96351f594e9abe7c220af12c8a179f604974c0cc Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:38:03 +0200 Subject: [PATCH 15/49] Add a popover to manage the layers visibility in map editor --- .../src/components/map_render/view_state.rs | 3 + dispel-gui/src/editors/map_editor/message.rs | 4 + dispel-gui/src/editors/map_editor/state.rs | 2 +- .../src/editors/map_editor/update/mod.rs | 14 + dispel-gui/src/editors/map_editor/view/mod.rs | 83 +++- gui-widgets/src/components/mod.rs | 2 + gui-widgets/src/components/popover.rs | 369 ++++++++++++++++++ 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 gui-widgets/src/components/popover.rs diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs index 13c0c0cc..e20efd0e 100644 --- a/dispel-gui/src/components/map_render/view_state.rs +++ b/dispel-gui/src/components/map_render/view_state.rs @@ -39,6 +39,8 @@ pub struct MapViewState { pub active_tool: MapTool, /// How the object-id brush is applied when the ObjectId tool clicks a tile. pub object_brush_mode: ObjectBrushMode, + /// Whether the layers dropdown popover is open. + pub layers_popover_open: bool, /// Selected sprite sequence index in the Sprites browser. pub selected_sprite_sequence: Option, /// Currently selected entity in the inspector panel. @@ -83,6 +85,7 @@ impl Default for MapViewState { view_mode: MapViewMode::Map, active_tool: MapTool::Pan, object_brush_mode: ObjectBrushMode::Paint, + layers_popover_open: false, selected_sprite_sequence: None, selected_entity: None, tile_layer_cache: canvas::Cache::new(), diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 1f490231..9f5b902e 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -212,6 +212,10 @@ pub enum MapEditorMessage { SelectTool(usize, MapTool), /// Set how the object-id brush is applied (Paint / Erase). SetObjectBrushMode(usize, ObjectBrushMode), + /// Toggle the layers dropdown popover open/closed. + ToggleLayersPopover(usize), + /// Close the layers dropdown popover. + CloseLayersPopover(usize), } /// Which entity or tile is currently selected in the map editor inspector. diff --git a/dispel-gui/src/editors/map_editor/state.rs b/dispel-gui/src/editors/map_editor/state.rs index 5d935fbd..4b62c4d1 100644 --- a/dispel-gui/src/editors/map_editor/state.rs +++ b/dispel-gui/src/editors/map_editor/state.rs @@ -89,7 +89,7 @@ pub struct ChoiceOption { /// State for the interactive conversation display. /// /// Walks the DLG graph sequentially, checking event gates and following -/// branches, exactly as the game engine would. +/// branches. #[derive(Debug, Clone)] pub struct ConversationState { /// Index of the NPC being conversed with. diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 27868f49..361cffa1 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -356,6 +356,20 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { MapEditorMessage::SelectTool(tab_id, tool) => map::select_tool(app, tab_id, tool), + MapEditorMessage::ToggleLayersPopover(tab_id) => { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { + state.view.layers_popover_open = !state.view.layers_popover_open; + } + Task::none() + } + + MapEditorMessage::CloseLayersPopover(tab_id) => { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { + state.view.layers_popover_open = false; + } + Task::none() + } + MapEditorMessage::SetObjectBrushMode(tab_id, mode) => { if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { state.view.object_brush_mode = mode; diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index 369ed5c5..b9afb503 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -218,17 +218,70 @@ pub fn view(app: &App) -> Element<'_, Message> { .spacing(8) .align_y(iced::Alignment::Center); - let layer_row = row![ + // ── Layers dropdown (popover) ───────────────────────────────── + let layer_visibility = [ + state.view.show_ground, + state.view.show_buildings, + state.view.show_roofs, + state.view.show_internal_sprites, + state.view.show_collisions, + state.view.show_events, + state.view.show_monsters, + state.view.show_npcs, + state.view.show_npc_waypoints, + state.view.show_objects, + state.view.show_draw_items, + state.view.show_object_ids, + ]; + let visible_layers = layer_visibility.iter().filter(|&&v| v).count(); + let total_layers = layer_visibility.len(); + + let layers_trigger = button( + row![ + text("LAYERS").size(11), + text("▾").size(10).style(style::subtle_text), + text(format!("· {}/{}", visible_layers, total_layers)) + .size(10) + .style(style::subtle_text), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ) + .padding([3, 10]) + .on_press(Message::map_editor(MapEditorMessage::ToggleLayersPopover( + tab_id, + ))) + .style(if state.view.layers_popover_open { + style::active_chip + } else { + style::chip + }); + + let layers_panel = container(column![ + segment_label("Terrain"), terrain_segment, - rule(), + h_rule(), + segment_label("Overlays"), overlay_segment, - rule(), + h_rule(), + segment_label("Entities"), entity_segment, - tile_status, - ] - .spacing(12) - .padding([6, 16]) - .align_y(iced::Alignment::Center); + ]) + .padding(10) + .style(style::panel_container); + + let layers_popover: Element<'_, Message> = gui_widgets::components::popover::popover( + layers_trigger, + layers_panel, + state.view.layers_popover_open, + move || Message::map_editor(MapEditorMessage::ToggleLayersPopover(tab_id)), + move || Message::map_editor(MapEditorMessage::CloseLayersPopover(tab_id)), + ); + + let layer_row = row![layers_popover, tile_status] + .spacing(12) + .padding([6, 16]) + .align_y(iced::Alignment::Center); // ── Action buttons row ───────────────────────────────────────── let can_undo = !state.data.undo_stack.is_empty(); @@ -596,6 +649,20 @@ fn segment_label(label: &'static str) -> Element<'static, Message> { .into() } +/// Thin horizontal separator between layer groups inside the popover panel. +fn h_rule() -> Element<'static, Message> { + container(text("")) + .width(Fill) + .height(iced::Length::Fixed(1.0)) + .style(|_| container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.6, 0.55, 0.45, 0.4, + ))), + ..container::Style::default() + }) + .into() +} + fn layer_toggle( label: &'static str, is_on: bool, diff --git a/gui-widgets/src/components/mod.rs b/gui-widgets/src/components/mod.rs index b48bfd6c..4cff5c14 100644 --- a/gui-widgets/src/components/mod.rs +++ b/gui-widgets/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod collapsible_tree; pub mod context_menu; pub mod modal; pub mod paragraph_cache; +pub mod popover; pub mod tab_bar; pub mod table_widget; pub mod text_area; @@ -11,5 +12,6 @@ pub use collapsible_tree::{CollapsibleTree, RenderContext, TreeNode}; pub use context_menu::ContextMenu; pub use modal::modal; pub use paragraph_cache::ParagraphCache; +pub use popover::popover; pub use table_widget::{RowFlags, TableColumn, TableState, TableWidget}; pub use text_area::{TextAreaContent, textarea, textarea_style}; diff --git a/gui-widgets/src/components/popover.rs b/gui-widgets/src/components/popover.rs new file mode 100644 index 00000000..aaa82620 --- /dev/null +++ b/gui-widgets/src/components/popover.rs @@ -0,0 +1,369 @@ +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, Widget}; +use iced::advanced::{self, Shell, renderer}; +use iced::keyboard::key; +use iced::{ + Color, Element, Event, Length, Point, Rectangle, Shadow, Size, Vector, keyboard, mouse, +}; + +/// Display an anchored popover panel above sibling content. +/// +/// The `trigger` element is laid out normally. When `open` is `true`, the +/// `panel` element floats directly below the trigger (clamped to the available +/// bounds). Clicking the trigger calls `on_toggle`; clicking outside the panel +/// or pressing Escape calls `on_blur`. +/// +/// Open/close state is caller-owned (mirroring how consumers drive [`crate::components::modal`]): +/// render this widget unconditionally and pass the current `open` flag. +pub fn popover<'a, Message, Theme, Renderer>( + trigger: impl Into>, + panel: impl Into>, + open: bool, + on_toggle: impl Fn() -> Message + 'a, + on_blur: impl Fn() -> Message + 'a, +) -> Element<'a, Message, Theme, Renderer> +where + Theme: 'a, + Renderer: 'a + advanced::Renderer, + Message: 'a, +{ + Popover::new(trigger, panel, open, on_toggle, on_blur).into() +} + +struct Popover<'a, Message, Theme, Renderer> { + trigger: Element<'a, Message, Theme, Renderer>, + panel: Element<'a, Message, Theme, Renderer>, + open: bool, + on_toggle: Box Message + 'a>, + on_blur: Box Message + 'a>, + shadow: Shadow, +} + +impl<'a, Message, Theme, Renderer> Popover<'a, Message, Theme, Renderer> { + fn new( + trigger: impl Into>, + panel: impl Into>, + open: bool, + on_toggle: impl Fn() -> Message + 'a, + on_blur: impl Fn() -> Message + 'a, + ) -> Self { + Self { + trigger: trigger.into(), + panel: panel.into(), + open, + on_toggle: Box::new(on_toggle), + on_blur: Box::new(on_blur), + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: Vector::new(0.0, 6.0), + blur_radius: 16.0, + }, + } + } +} + +impl Widget + for Popover<'_, Message, Theme, Renderer> +where + Renderer: advanced::Renderer, +{ + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(&mut [&mut self.trigger, &mut self.panel]); + } + + fn size(&self) -> Size { + self.trigger.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let trigger = self + .trigger + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits); + let size = trigger.size(); + + // Lay out the panel; when open, anchor it below the trigger + // (left-aligned, clamped to the available bounds). + let panel_limits = layout::Limits::new(Size::ZERO, limits.max()) + .width(Length::Shrink) + .height(Length::Shrink); + let panel = if self.open { + let mut panel = + self.panel + .as_widget_mut() + .layout(&mut tree.children[1], renderer, &panel_limits); + let panel_size = panel.size(); + let max = limits.max(); + let x = trigger + .bounds() + .x + .min((max.width - panel_size.width).max(0.0)); + panel.move_to_mut(Point::new(x.max(0.0), size.height)); + panel + } else { + // Keep the child slot present for state consistency, but empty. + layout::Node::new(Size::ZERO) + }; + + layout::Node::with_children(size, vec![trigger, panel]) + } + + fn update( + &mut self, + state: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let mut children = layout.children(); + let trigger_layout = children.next().unwrap(); + let panel_layout = children.next().unwrap(); + + match event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key::Named::Escape), + .. + }) if self.open => { + shell.publish((self.on_blur)()); + shell.capture_event(); + return; + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if cursor.is_over(trigger_layout.bounds()) { + shell.publish((self.on_toggle)()); + shell.capture_event(); + return; + } + if self.open && !cursor.is_over(panel_layout.bounds()) { + shell.publish((self.on_blur)()); + shell.capture_event(); + return; + } + } + _ => {} + } + + self.trigger.as_widget_mut().update( + &mut state.children[0], + event, + trigger_layout, + cursor, + renderer, + shell, + viewport, + ); + if self.open { + self.panel.as_widget_mut().update( + &mut state.children[1], + event, + panel_layout, + cursor, + renderer, + shell, + viewport, + ); + } + } + + fn draw( + &self, + state: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let mut children = layout.children(); + let trigger_layout = children.next().unwrap(); + let panel_layout = children.next().unwrap(); + + self.trigger.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + trigger_layout, + cursor, + viewport, + ); + + if self.open { + renderer.with_layer(layout.bounds(), |renderer| { + renderer.fill_quad( + renderer::Quad { + bounds: panel_layout.bounds(), + border: iced::Border { + radius: 4.0.into(), + ..iced::Border::default() + }, + shadow: self.shadow, + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + self.panel.as_widget().draw( + &state.children[1], + renderer, + theme, + style, + panel_layout, + cursor, + viewport, + ); + }); + } + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.trigger.as_widget_mut().overlay( + &mut state.children[0], + layout.children().next().unwrap(), + renderer, + viewport, + translation, + ) + } + + fn mouse_interaction( + &self, + state: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let mut children = layout.children(); + let trigger_layout = children.next().unwrap(); + let panel_layout = children.next().unwrap(); + + if self.open && cursor.is_over(panel_layout.bounds()) { + self.panel.as_widget().mouse_interaction( + &state.children[1], + panel_layout, + cursor, + viewport, + renderer, + ) + } else { + self.trigger.as_widget().mouse_interaction( + &state.children[0], + trigger_layout, + cursor, + viewport, + renderer, + ) + } + } + + fn operate( + &mut self, + state: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + let mut children = layout.children(); + let trigger_layout = children.next().unwrap(); + let panel_layout = children.next().unwrap(); + + self.trigger.as_widget_mut().operate( + &mut state.children[0], + trigger_layout, + renderer, + operation, + ); + if self.open { + self.panel.as_widget_mut().operate( + &mut state.children[1], + panel_layout, + renderer, + operation, + ); + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Theme: 'a, + Renderer: 'a + advanced::Renderer, + Message: 'a, +{ + fn from(p: Popover<'a, Message, Theme, Renderer>) -> Self { + Element::new(p) + } +} + +#[cfg(test)] +mod tests { + use iced::widget::{button, container}; + + use super::*; + + #[test] + fn test_popover_creation_closed() { + let trigger = button("Layers"); + let panel = container("Panel content"); + let result = popover(trigger, panel, false, || "toggle", || "blur"); + let _: Element<'_, &str, iced::Theme, iced::Renderer> = result; + } + + #[test] + fn test_popover_creation_open() { + let trigger = button("Layers"); + let panel = container("Panel content"); + let result = popover(trigger, panel, true, || "toggle", || "blur"); + let _: Element<'_, &str, iced::Theme, iced::Renderer> = result; + } + + #[test] + fn test_popover_size_follows_trigger() { + // Compile-level check: the widget's `size()` delegates to the trigger; + // constructing with differently sized triggers must be valid. + let result = popover("tiny", container(column_of_text()), true, || "t", || "b"); + let _: Element<'_, &str, iced::Theme, iced::Renderer> = result; + } + + fn column_of_text() -> iced::widget::Column<'static, &'static str, iced::Theme> { + iced::widget::column!["a", "b", "c"] + } + + #[derive(Debug, Clone)] + enum TestMsg { + Toggle, + Blur, + ItemSelected, + } + + #[test] + fn test_popover_different_message_types() { + let trigger = button("Trigger").on_press(TestMsg::Toggle); + let panel = button("Item").on_press(TestMsg::ItemSelected); + let result = popover(trigger, panel, true, || TestMsg::Toggle, || TestMsg::Blur); + let _: Element<'_, TestMsg, iced::Theme, iced::Renderer> = result; + } + + #[test] + fn test_popover_into_element_helper() { + let result = popover("trigger", "panel", false, || "toggle", || "blur"); + let _: Element<'_, &str, iced::Theme, iced::Renderer> = result; + } +} From 39d8f49bd467b1a92e369360ad4ddf77c21956e1 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:46:28 +0200 Subject: [PATCH 16/49] Disable the foreign keys --- src/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database.rs b/src/database.rs index 1e420a07..dc4920aa 100644 --- a/src/database.rs +++ b/src/database.rs @@ -5,7 +5,7 @@ use rusqlite::{Connection, Result}; pub fn initialize_database(conn: &Connection) -> Result<()> { // Optimization and safety PRAGMAs conn.execute_batch( - "PRAGMA foreign_keys = ON; + "PRAGMA foreign_keys = OFF; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -64000; From 47148c5054efb833815a83645667d130035bb3b9 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:56:30 +0200 Subject: [PATCH 17/49] Fix popover display --- gui-widgets/src/components/popover.rs | 315 ++++++++++++++++---------- 1 file changed, 190 insertions(+), 125 deletions(-) diff --git a/gui-widgets/src/components/popover.rs b/gui-widgets/src/components/popover.rs index aaa82620..7bbbe200 100644 --- a/gui-widgets/src/components/popover.rs +++ b/gui-widgets/src/components/popover.rs @@ -9,9 +9,10 @@ use iced::{ /// Display an anchored popover panel above sibling content. /// /// The `trigger` element is laid out normally. When `open` is `true`, the -/// `panel` element floats directly below the trigger (clamped to the available -/// bounds). Clicking the trigger calls `on_toggle`; clicking outside the panel -/// or pressing Escape calls `on_blur`. +/// `panel` element floats directly below the trigger (clamped to the window, +/// flipped above if there is no room) via the overlay layer, so it renders +/// above any sibling widgets. Clicking the trigger calls `on_toggle`; clicking +/// outside the panel or pressing Escape calls `on_blur`. /// /// Open/close state is caller-owned (mirroring how consumers drive [`crate::components::modal`]): /// render this widget unconditionally and pass the current `open` flag. @@ -81,36 +82,14 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { + // Only the trigger participates in layout; the panel is rendered by + // the overlay layer (see `overlay`). let trigger = self .trigger .as_widget_mut() .layout(&mut tree.children[0], renderer, limits); - let size = trigger.size(); - - // Lay out the panel; when open, anchor it below the trigger - // (left-aligned, clamped to the available bounds). - let panel_limits = layout::Limits::new(Size::ZERO, limits.max()) - .width(Length::Shrink) - .height(Length::Shrink); - let panel = if self.open { - let mut panel = - self.panel - .as_widget_mut() - .layout(&mut tree.children[1], renderer, &panel_limits); - let panel_size = panel.size(); - let max = limits.max(); - let x = trigger - .bounds() - .x - .min((max.width - panel_size.width).max(0.0)); - panel.move_to_mut(Point::new(x.max(0.0), size.height)); - panel - } else { - // Keep the child slot present for state consistency, but empty. - layout::Node::new(Size::ZERO) - }; - layout::Node::with_children(size, vec![trigger, panel]) + layout::Node::with_children(trigger.size(), vec![trigger, layout::Node::new(Size::ZERO)]) } fn update( @@ -125,30 +104,16 @@ where ) { let mut children = layout.children(); let trigger_layout = children.next().unwrap(); - let panel_layout = children.next().unwrap(); - match event { - Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(key::Named::Escape), - .. - }) if self.open => { - shell.publish((self.on_blur)()); + if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event { + // Toggle only when closed. While open, the overlay owns all + // clicks (outside-panel press closes it), so handling the + // trigger here too would immediately re-open after a blur. + if !self.open && cursor.is_over(trigger_layout.bounds()) { + shell.publish((self.on_toggle)()); shell.capture_event(); return; } - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { - if cursor.is_over(trigger_layout.bounds()) { - shell.publish((self.on_toggle)()); - shell.capture_event(); - return; - } - if self.open && !cursor.is_over(panel_layout.bounds()) { - shell.publish((self.on_blur)()); - shell.capture_event(); - return; - } - } - _ => {} } self.trigger.as_widget_mut().update( @@ -160,17 +125,6 @@ where shell, viewport, ); - if self.open { - self.panel.as_widget_mut().update( - &mut state.children[1], - event, - panel_layout, - cursor, - renderer, - shell, - viewport, - ); - } } fn draw( @@ -183,9 +137,7 @@ where cursor: mouse::Cursor, viewport: &Rectangle, ) { - let mut children = layout.children(); - let trigger_layout = children.next().unwrap(); - let panel_layout = children.next().unwrap(); + let trigger_layout = layout.children().next().unwrap(); self.trigger.as_widget().draw( &state.children[0], @@ -196,32 +148,6 @@ where cursor, viewport, ); - - if self.open { - renderer.with_layer(layout.bounds(), |renderer| { - renderer.fill_quad( - renderer::Quad { - bounds: panel_layout.bounds(), - border: iced::Border { - radius: 4.0.into(), - ..iced::Border::default() - }, - shadow: self.shadow, - ..renderer::Quad::default() - }, - Color::TRANSPARENT, - ); - self.panel.as_widget().draw( - &state.children[1], - renderer, - theme, - style, - panel_layout, - cursor, - viewport, - ); - }); - } } fn overlay<'b>( @@ -232,13 +158,29 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { - self.trigger.as_widget_mut().overlay( - &mut state.children[0], - layout.children().next().unwrap(), - renderer, - viewport, - translation, - ) + let mut children = layout.children(); + let trigger_layout = children.next().unwrap(); + + if !self.open { + return self.trigger.as_widget_mut().overlay( + &mut state.children[0], + trigger_layout, + renderer, + viewport, + translation, + ); + } + + let bounds = trigger_layout.bounds(); + Some(advanced::overlay::Element::new(Box::new(PopoverOverlay { + position: bounds.position() + translation, + target_height: bounds.height, + viewport: *viewport, + tree: &mut state.children[1], + panel: &mut self.panel, + on_blur: &self.on_blur, + shadow: self.shadow, + }))) } fn mouse_interaction( @@ -249,27 +191,15 @@ where viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { - let mut children = layout.children(); - let trigger_layout = children.next().unwrap(); - let panel_layout = children.next().unwrap(); + let trigger_layout = layout.children().next().unwrap(); - if self.open && cursor.is_over(panel_layout.bounds()) { - self.panel.as_widget().mouse_interaction( - &state.children[1], - panel_layout, - cursor, - viewport, - renderer, - ) - } else { - self.trigger.as_widget().mouse_interaction( - &state.children[0], - trigger_layout, - cursor, - viewport, - renderer, - ) - } + self.trigger.as_widget().mouse_interaction( + &state.children[0], + trigger_layout, + cursor, + viewport, + renderer, + ) } fn operate( @@ -279,9 +209,7 @@ where renderer: &Renderer, operation: &mut dyn widget::Operation, ) { - let mut children = layout.children(); - let trigger_layout = children.next().unwrap(); - let panel_layout = children.next().unwrap(); + let trigger_layout = layout.children().next().unwrap(); self.trigger.as_widget_mut().operate( &mut state.children[0], @@ -289,14 +217,151 @@ where renderer, operation, ); - if self.open { - self.panel.as_widget_mut().operate( - &mut state.children[1], - panel_layout, - renderer, - operation, - ); + } +} + +/// The floating panel of an open [`Popover`], rendered above all siblings. +struct PopoverOverlay<'a, 'b, Message, Theme, Renderer> { + position: Point, + target_height: f32, + viewport: Rectangle, + tree: &'a mut widget::Tree, + panel: &'a mut Element<'b, Message, Theme, Renderer>, + on_blur: &'a dyn Fn() -> Message, + shadow: Shadow, +} + +impl advanced::overlay::Overlay + for PopoverOverlay<'_, '_, Message, Theme, Renderer> +where + Renderer: advanced::Renderer, +{ + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let space_below = bounds.height - (self.position.y + self.target_height); + let space_above = self.position.y; + + let limits = layout::Limits::new( + Size::ZERO, + Size::new( + (bounds.width - self.position.x).max(0.0), + if space_below > space_above { + space_below.max(0.0) + } else { + space_above.max(0.0) + }, + ), + ) + .width(Length::Shrink) + .height(Length::Shrink); + + let node = self + .panel + .as_widget_mut() + .layout(self.tree, renderer, &limits); + let size = node.size(); + + // Clamp horizontally so the panel stays inside the window. + let x = self.position.x.min((bounds.width - size.width).max(0.0)); + + // Prefer below the trigger; flip above when there is no room. + let y = if space_below >= space_above || space_below + self.target_height >= size.height { + self.position.y + self.target_height + } else { + (self.position.y - size.height).max(0.0) + }; + + node.move_to(Point::new(x.max(0.0), y)) + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + ) { + let bounds = layout.bounds(); + + match event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key::Named::Escape), + .. + }) => { + shell.publish((self.on_blur)()); + shell.capture_event(); + return; + } + // Click anywhere outside the panel (including the trigger) + // dismisses it. + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + if !cursor.is_over(bounds) => + { + shell.publish((self.on_blur)()); + shell.capture_event(); + return; + } + _ => {} } + + self.panel + .as_widget_mut() + .update(self.tree, event, layout, cursor, renderer, shell, &bounds); + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let bounds = layout.bounds(); + + // Drop shadow behind the panel. + renderer.fill_quad( + renderer::Quad { + bounds, + border: iced::Border { + radius: 4.0.into(), + ..iced::Border::default() + }, + shadow: self.shadow, + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + + self.panel + .as_widget() + .draw(self.tree, renderer, theme, style, layout, cursor, &bounds); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + ) -> mouse::Interaction { + self.panel.as_widget().mouse_interaction( + self.tree, + layout, + cursor, + &self.viewport, + renderer, + ) + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.panel + .as_widget_mut() + .operate(self.tree, layout, renderer, operation); } } From c5f7088bd3b176e5f3334748fa5794db1d461ea1 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:07:16 +0200 Subject: [PATCH 18/49] Redesign the popover --- dispel-gui/src/editors/map_editor/view/mod.rs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index b9afb503..77f62068 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -116,8 +116,9 @@ pub fn view(app: &App) -> Element<'_, Message> { text("Decoding tiles…").size(10).style(style::subtle_text) }; - let terrain_segment = row![ - segment_label("Terrain"), + // Vertical toggle groups for the popover panel. Group headers live + // in the panel itself (exactly one per group). + let terrain_group = column![ layer_toggle( "Ground", state.view.show_ground, @@ -126,7 +127,7 @@ pub fn view(app: &App) -> Element<'_, Message> { None ), layer_toggle( - "Bldg", + "Buildings", state.view.show_buildings, tab_id, MapLayer::Buildings, @@ -147,11 +148,9 @@ pub fn view(app: &App) -> Element<'_, Message> { None ), ] - .spacing(8) - .align_y(iced::Alignment::Center); + .spacing(6); - let overlay_segment = row![ - segment_label("Overlays"), + let overlay_group = column![ layer_toggle( "Collisions", state.view.show_collisions, @@ -167,34 +166,32 @@ pub fn view(app: &App) -> Element<'_, Message> { None ), layer_toggle( - "Obj IDs", + "Object IDs", state.view.show_object_ids, tab_id, MapLayer::ObjectIds, None ), ] - .spacing(8) - .align_y(iced::Alignment::Center); + .spacing(6); - let entity_segment = row![ - segment_label("Entities"), + let entity_group = column![ layer_toggle( - "Mon", + "Monsters", state.view.show_monsters, tab_id, MapLayer::Monsters, Some(state.data.monsters.len()) ), layer_toggle( - "NPC", + "NPCs", state.view.show_npcs, tab_id, MapLayer::Npcs, Some(state.data.npcs.len()) ), layer_toggle( - "Wpts", + "Waypoints", state.view.show_npc_waypoints, tab_id, MapLayer::NpcWaypoints, @@ -215,8 +212,7 @@ pub fn view(app: &App) -> Element<'_, Message> { Some(state.data.draw_items.len()) ), ] - .spacing(8) - .align_y(iced::Alignment::Center); + .spacing(6); // ── Layers dropdown (popover) ───────────────────────────────── let layer_visibility = [ @@ -257,17 +253,21 @@ pub fn view(app: &App) -> Element<'_, Message> { style::chip }); - let layers_panel = container(column![ - segment_label("Terrain"), - terrain_segment, - h_rule(), - segment_label("Overlays"), - overlay_segment, - h_rule(), - segment_label("Entities"), - entity_segment, - ]) - .padding(10) + let layers_panel = container( + column![ + segment_label("Terrain"), + terrain_group, + h_rule(), + segment_label("Overlays"), + overlay_group, + h_rule(), + segment_label("Entities"), + entity_group, + ] + .spacing(12), + ) + .padding(14) + .width(iced::Length::Fixed(200.0)) .style(style::panel_container); let layers_popover: Element<'_, Message> = gui_widgets::components::popover::popover( @@ -671,7 +671,7 @@ fn layer_toggle( count: Option, ) -> Element<'static, Message> { let label_str: String = match count { - Some(n) => format!("{}({})", label, n), + Some(n) => format!("{} ({})", label, n), None => label.to_string(), }; toggler(is_on) From d6c664244262a1073f1f34fc445ffdec95c43ca3 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:29:08 +0200 Subject: [PATCH 19/49] Redesign the map toolbar --- .../editors/map_editor/update/persistence.rs | 53 +-- dispel-gui/src/editors/map_editor/view/mod.rs | 324 ++++++++---------- 2 files changed, 170 insertions(+), 207 deletions(-) diff --git a/dispel-gui/src/editors/map_editor/update/persistence.rs b/dispel-gui/src/editors/map_editor/update/persistence.rs index f1cb9f08..31ed1a70 100644 --- a/dispel-gui/src/editors/map_editor/update/persistence.rs +++ b/dispel-gui/src/editors/map_editor/update/persistence.rs @@ -249,19 +249,23 @@ pub fn save_complete( match result { Ok(msg) => { state.data.dirty = false; - state.data.status_msg = Some(msg); + state.data.notify( + gui_widgets::components::toast::Status::Success, + "Saved", + msg, + ); } Err(e) => { - state.data.status_msg = Some(format!("Save failed: {e}")); + state + .data + .notify(gui_widgets::components::toast::Status::Danger, "Error", e); } } } if success { super::set_tab_modified(app, tab_id, false); - super::dismiss_status_after(tab_id) - } else { - Task::none() } + Task::none() } pub fn map_saved(app: &mut App, tab_id: usize, result: Result) -> Task { @@ -271,19 +275,23 @@ pub fn map_saved(app: &mut App, tab_id: usize, result: Result) - match result { Ok(msg) => { state.data.dirty = false; - state.data.status_msg = Some(msg); + state.data.notify( + gui_widgets::components::toast::Status::Success, + "Saved", + msg, + ); } Err(e) => { - state.data.status_msg = Some(format!("Save failed: {e}")); + state + .data + .notify(gui_widgets::components::toast::Status::Danger, "Error", e); } } } if success { super::set_tab_modified(app, tab_id, false); - super::dismiss_status_after(tab_id) - } else { - Task::none() } + Task::none() } pub fn export_image(app: &mut App, tab_id: usize) -> Task { @@ -378,19 +386,24 @@ pub fn export_complete( tab_id: usize, result: Result, ) -> Task { - let success = result.is_ok(); if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { state.data.is_exporting = false; - state.data.status_msg = Some(match result { - Ok(msg) => msg, - Err(e) => format!("Export failed: {e}"), - }); - } - if success { - super::dismiss_status_after(tab_id) - } else { - Task::none() + match result { + Ok(msg) => { + state.data.notify( + gui_widgets::components::toast::Status::Success, + "Export", + msg, + ); + } + Err(e) => { + state + .data + .notify(gui_widgets::components::toast::Status::Danger, "Error", e); + } + } } + Task::none() } #[cfg(test)] diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index 77f62068..ea3a3570 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -75,8 +75,6 @@ pub fn view(app: &App) -> Element<'_, Message> { LoadingState::Loaded(map_handle) => { let map_data = &map_handle.0; let model = &map_data.model; - let gtl_count = map_data.gtl_tiles.len(); - let btl_count = map_data.btl_tiles.len(); let path_label = state .data @@ -86,35 +84,7 @@ pub fn view(app: &App) -> Element<'_, Message> { .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Unknown".to_string()); - // ── Info row ───────────────────────────────────────────────── - let info_row = row![ - info_cell("File", &path_label), - info_cell( - "Tiles (W×H)", - &format!("{}×{}", model.tiled_map_width, model.tiled_map_height) - ), - info_cell("GTL", >l_count.to_string()), - info_cell("BTL", &btl_count.to_string()), - info_cell( - "Entities", - &format!( - "{}M {}N {}O {}D", - state.data.monsters.len(), - state.data.npcs.len(), - state.data.extra_refs.len(), - state.data.draw_items.len() - ) - ), - ] - .spacing(16) - .padding([8, 16]); - // ── Layer toggles, grouped into three segments ──────────────── - let tile_status = if state.data.tiles_ready { - text("").size(10).style(style::subtle_text) - } else { - text("Decoding tiles…").size(10).style(style::subtle_text) - }; // Vertical toggle groups for the popover panel. Group headers live // in the panel itself (exactly one per group). @@ -278,12 +248,7 @@ pub fn view(app: &App) -> Element<'_, Message> { move || Message::map_editor(MapEditorMessage::CloseLayersPopover(tab_id)), ); - let layer_row = row![layers_popover, tile_status] - .spacing(12) - .padding([6, 16]) - .align_y(iced::Alignment::Center); - - // ── Action buttons row ───────────────────────────────────────── + // ── Action buttons (Row B) ──────────────────────────────────── let can_undo = !state.data.undo_stack.is_empty(); let can_redo = !state.data.redo_stack.is_empty(); let save_label = if state.data.is_saving { @@ -299,12 +264,13 @@ pub fn view(app: &App) -> Element<'_, Message> { save_btn.on_press(Message::map_editor(MapEditorMessage::SaveMap(tab_id))); } + // Icon + label undo/redo — icons alone are anti-UX. let mut undo_btn = button( row![ text(icon_char(Icon::Undo2)).font(LUCIDE_FONT).size(11), - text(" Undo").size(11), + text("Undo").size(11), ] - .spacing(2), + .spacing(4), ) .padding([3, 8]); if can_undo { @@ -313,10 +279,10 @@ pub fn view(app: &App) -> Element<'_, Message> { let mut redo_btn = button( row![ - text("Redo ").size(11), text(icon_char(Icon::Redo2)).font(LUCIDE_FONT).size(11), + text("Redo").size(11), ] - .spacing(2), + .spacing(4), ) .padding([3, 8]); if can_redo { @@ -328,13 +294,17 @@ pub fn view(app: &App) -> Element<'_, Message> { } else { "Export PNG" }; - let mut export_btn = button(text(export_label).size(11)).padding([3, 8]); + let mut export_btn = button(text(export_label).size(11)) + .padding([3, 8]) + .accessible_label("Export map as PNG"); if !state.data.is_exporting { export_btn = export_btn.on_press(Message::map_editor(MapEditorMessage::ExportImage(tab_id))); } - let mut tmx_btn = button(text("Export TMX…").size(11)).padding([3, 8]); + let mut tmx_btn = button(text("Export TMX").size(11)) + .padding([3, 8]) + .accessible_label("Export map as TMX"); if !state.data.is_exporting { tmx_btn = tmx_btn.on_press(Message::map_editor(MapEditorMessage::ExportTmx(tab_id))); @@ -355,27 +325,58 @@ pub fn view(app: &App) -> Element<'_, Message> { state.data.gtl_handles.len() ); - let action_row = row![ + // Per-tool click hint. Hidden for Pan (default tool needs no help + // text); always visible for editing tools. + let hint: Option = match state.view.active_tool { + MapTool::Pan => None, + MapTool::Collision => Some("Click tile: block/unblock".into()), + MapTool::ObjectId => Some(match state.view.object_brush_mode { + ObjectBrushMode::Paint => { + format!("Click: paint obj {}", state.data.object_brush) + } + ObjectBrushMode::Erase => "Click: erase any value".into(), + }), + MapTool::EventInspect => Some("Click tile: inspect event".into()), + }; + + let actions_row = row![ save_btn, undo_btn, redo_btn, export_btn, tmx_btn, + text(path_label).size(10).style(style::subtle_text), status_text, horizontal_space(), - text(summary_text).size(10).style(style::subtle_text), + match &hint { + Some(h) => Element::new(text(h.clone()).size(10).style(style::subtle_text)), + None => Element::new(horizontal_space()), + }, ] .spacing(6) .padding([4, 16]) .align_y(iced::Alignment::Center); - // ── Tools row ──────────────────────────────────────────────────── + // ── Row A: context row ──────────────────────────────────────── + let mode_chip = |label: &'static str, mode: MapViewMode| { + button(text(label).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SwitchViewMode( + tab_id, mode, + ))) + .padding([3, 8]) + .style(if state.view.view_mode == mode { + style::active_chip + } else { + style::chip + }) + }; + let tool_chip = |label: &'static str, tool: MapTool| { button(text(label).size(11)) .on_press(Message::map_editor(MapEditorMessage::SelectTool( tab_id, tool, ))) - .padding([3, 10]) + .padding([3, 8]) .style(if state.view.active_tool == tool { style::active_chip } else { @@ -383,141 +384,99 @@ pub fn view(app: &App) -> Element<'_, Message> { }) }; - let tools_row = row![ - text("Tools:").size(11).style(style::subtle_text), - tool_chip("Pan", MapTool::Pan), - tool_chip("Collide", MapTool::Collision), - tool_chip("Obj ID", MapTool::ObjectId), - tool_chip("Event", MapTool::EventInspect), - ] - .spacing(4) - .padding([6, 16]) - .align_y(iced::Alignment::Center); - - // ── Contextual object-id brush options (Obj ID tool only) ───── - let obj_brush_options: Element<'_, Message> = if state.view.active_tool - == MapTool::ObjectId - { - let mode_chip = |label: &'static str, mode: ObjectBrushMode| { - button(text(label).size(11)) - .on_press(Message::map_editor(MapEditorMessage::SetObjectBrushMode( - tab_id, mode, - ))) - .padding([3, 8]) - .style(if state.view.object_brush_mode == mode { - style::active_chip - } else { - style::chip - }) - }; - let brush = state.data.object_brush; - let preset_chip = |n: i32| { - button(text(n.to_string()).size(11)) - .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( - tab_id, n, - ))) - .padding([3, 8]) - .style(style::chip) - }; - let brush_str = brush.to_string(); - row![ - mode_chip("Paint", ObjectBrushMode::Paint), - mode_chip("Erase", ObjectBrushMode::Erase), - rule(), - button(text("−").size(12)) - .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( - tab_id, - brush - 1 - ))) - .padding([3, 8]) - .style(style::chip), - container( - text_input("1", brush_str) - .width(iced::Length::Fixed(48.0)) - .on_input(move |v: String| { - let val = v.parse::().unwrap_or(1).clamp(1, 511); - Message::map_editor(MapEditorMessage::SetObjectBrush(tab_id, val)) - }), + // Contextual object-id brush options — inline in Row A, only when + // the Obj ID tool is active. + let brush_slot: Option> = + if state.view.active_tool == MapTool::ObjectId { + let mode_chip = |label: &'static str, mode: ObjectBrushMode| { + button(text(label).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrushMode( + tab_id, mode, + ))) + .padding([3, 6]) + .style(if state.view.object_brush_mode == mode { + style::active_chip + } else { + style::chip + }) + }; + let brush = state.data.object_brush; + let preset_chip = |n: i32| { + button(text(n.to_string()).size(11)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, n, + ))) + .padding([3, 5]) + .style(style::chip) + }; + let brush_str = brush.to_string(); + Some( + row![ + rule(), + mode_chip("Paint", ObjectBrushMode::Paint), + mode_chip("Erase", ObjectBrushMode::Erase), + rule(), + button(text("−").size(12)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, + brush - 1 + ))) + .padding([3, 6]) + .style(style::chip), + container( + text_input("1", brush_str) + .width(iced::Length::Fixed(44.0)) + .on_input(move |v: String| { + let val = v.parse::().unwrap_or(1).clamp(1, 511); + Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, val, + )) + }), + ) + .padding([2, 4]) + .style(style::info_card), + button(text("+").size(12)) + .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( + tab_id, + brush + 1 + ))) + .padding([3, 6]) + .style(style::chip), + preset_chip(1), + preset_chip(2), + preset_chip(3), + preset_chip(5), + preset_chip(10), + ] + .spacing(3) + .align_y(iced::Alignment::Center) + .into(), ) - .padding([2, 4]) - .style(style::info_card), - button(text("+").size(12)) - .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush( - tab_id, - brush + 1 - ))) - .padding([3, 8]) - .style(style::chip), - text("Presets:").size(10).style(style::subtle_text), - preset_chip(1), - preset_chip(2), - preset_chip(3), - preset_chip(5), - preset_chip(10), - ] - .spacing(4) - .padding([0, 16]) - .align_y(iced::Alignment::Center) - .into() - } else { - Element::new(horizontal_space()) - }; - - // Hint line describing what a click currently does. - let hint: String = match state.view.active_tool { - MapTool::Pan => "Click: select entity · drag: pan".into(), - MapTool::Collision => "Click tile: block/unblock".into(), - MapTool::ObjectId => match state.view.object_brush_mode { - ObjectBrushMode::Paint => { - format!("Click: paint obj {}", state.data.object_brush) - } - ObjectBrushMode::Erase => "Click: erase any value".into(), - }, - MapTool::EventInspect => "Click tile: inspect event".into(), - }; - let hint_row = row![text(hint).size(10).style(style::subtle_text)].padding([0, 16]); + } else { + None + }; - let mode_tab_row = row![ - button(text("Map").size(11)) - .on_press(Message::map_editor(MapEditorMessage::SwitchViewMode( - tab_id, - MapViewMode::Map - ))) - .padding([3, 10]) - .style(if state.view.view_mode == MapViewMode::Map { - style::active_chip - } else { - style::chip - }), - button(text("Sprites").size(11)) - .on_press(Message::map_editor(MapEditorMessage::SwitchViewMode( - tab_id, - MapViewMode::Sprites - ))) - .padding([3, 10]) - .style(if state.view.view_mode == MapViewMode::Sprites { - style::active_chip - } else { - style::chip - }), - ] - .spacing(4) - .padding([6, 16]); + let mut context_row = row![].spacing(6).padding([4, 16]); + context_row = context_row + .push(mode_chip("Map", MapViewMode::Map)) + .push(mode_chip("Sprites", MapViewMode::Sprites)) + .push(rule()) + .push(tool_chip("Pan", MapTool::Pan)) + .push(tool_chip("Collide", MapTool::Collision)) + .push(tool_chip("Obj ID", MapTool::ObjectId)) + .push(tool_chip("Event", MapTool::EventInspect)); + if let Some(slot) = brush_slot { + context_row = context_row.push(slot); + } + context_row = context_row + .push(horizontal_space()) + .push(text(summary_text).size(10).style(style::subtle_text)) + .push(layers_popover); - let toolbar = container( - column![ - row![mode_tab_row, action_row].spacing(0), - row![info_row].spacing(0), - tools_row, - obj_brush_options, - hint_row, - row![layer_row].spacing(0).align_y(iced::Alignment::Center), - ] - .spacing(0), - ) - .width(Fill) - .style(style::toolbar_container) - .accessible_label("Map editor toolbar"); + let toolbar = container(column![context_row, actions_row].spacing(0)) + .width(Fill) + .style(style::toolbar_container) + .accessible_label("Map editor toolbar"); // ── Canvas for tile layers, sprites (images) ─────────────────────── let tiles_canvas = canvas(MapCanvasTilesLayer { state }) @@ -619,15 +578,6 @@ fn horizontal_space() -> Space { Space::new().width(Fill) } -fn info_cell<'a>(label: &'static str, value: &str) -> Element<'a, Message> { - column![ - text(label).size(10).style(style::subtle_text), - text(value.to_string()).size(11), - ] - .spacing(2) - .into() -} - /// Thin vertical separator between layer-toggle segments. fn rule() -> Element<'static, Message> { container(text("")) From 9a82d29f7bc6708ca4e9275a16d835831ea3459c Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:46:19 +0200 Subject: [PATCH 20/49] Make use of the toast instead of status bar in the map editor --- dispel-gui/src/editors/map_editor/message.rs | 4 +- dispel-gui/src/editors/map_editor/state.rs | 111 +++++++++++++++++- .../editors/map_editor/update/conversation.rs | 12 +- .../src/editors/map_editor/update/dialog.rs | 13 +- .../src/editors/map_editor/update/entity.rs | 7 +- .../src/editors/map_editor/update/map.rs | 57 ++++++++- .../src/editors/map_editor/update/mod.rs | 85 +++++++------- dispel-gui/src/editors/map_editor/view/mod.rs | 20 ++-- 8 files changed, 241 insertions(+), 68 deletions(-) diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 9f5b902e..6ea14df1 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -152,8 +152,8 @@ pub enum MapEditorMessage { ExportImage(usize), /// Async export completed. Ok carries the output path; Err the error. ExportComplete(usize, Result), - /// Clear the status message (auto-dismiss after a delay). - ClearStatus(usize), + /// Dismiss the toast at `index` (close button or timeout). + DismissToast(usize, usize), /// Switch between Map and Sprites view modes. SwitchViewMode(usize, MapViewMode), /// Select a sprite sequence in the browser (None = deselect). diff --git a/dispel-gui/src/editors/map_editor/state.rs b/dispel-gui/src/editors/map_editor/state.rs index 4b62c4d1..da1d4d39 100644 --- a/dispel-gui/src/editors/map_editor/state.rs +++ b/dispel-gui/src/editors/map_editor/state.rs @@ -3,6 +3,7 @@ use crate::components::loading_state::LoadingState; pub use crate::components::map_render::{EntitySpriteHandle, InternalSpriteHandle, MapViewState}; use dispel_core::references::dialogue_paragraph::DialogueParagraph; use dispel_core::references::dialogue_script::DialogueScript; +use gui_widgets::components::toast::{Status, Toast}; use iced::widget::image::Handle; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -168,8 +169,8 @@ pub struct MapDataState { pub is_saving: bool, /// True while an async PNG export is in flight. pub is_exporting: bool, - /// Last save/export status message for display in the toolbar. - pub status_msg: Option, + /// Toast notifications (top-right overlay, auto-dismissed). + pub toasts: Vec, /// Sprite export dialog state (None = dialog closed). pub sprite_export_dialog: Option, /// Object-id brush value for painting (1–511, default 1). @@ -205,18 +206,69 @@ impl Default for MapDataState { dirty: false, is_saving: false, is_exporting: false, - status_msg: None, + toasts: Vec::new(), sprite_export_dialog: None, object_brush: 1, } } } +/// Maximum number of toasts kept on screen. +const MAX_TOASTS: usize = 4; + impl MapDataState { pub fn map_data(&self) -> Option<&MapDataHandle> { self.loading_state.data() } + /// Push a toast notification. + /// + /// Skips the push when it is identical (title + body) to the LAST toast, + /// deduping rapid repeats; caps the list at [`MAX_TOASTS`] by dropping + /// the oldest. + pub fn notify(&mut self, status: Status, title: impl Into, body: impl Into) { + let title = title.into(); + let body = body.into(); + if let Some(last) = self.toasts.last() + && last.title == title + && last.body == body + { + return; + } + self.toasts.push(Toast { + title, + body, + status, + }); + if self.toasts.len() > MAX_TOASTS { + self.toasts.remove(0); + } + } + + /// Push a toast, replacing the previous toast of the same `kind` instead + /// of stacking. Kinds are matched by title equality, so pass the same + /// `kind` and `title` for all events of one class (e.g. `"Collision"`). + /// Keeps the stack clean during paint strokes / rapid repeats. + pub fn notify_replace( + &mut self, + kind: &'static str, + status: Status, + title: impl Into, + body: impl Into, + ) { + let title = title.into(); + let body = body.into(); + if let Some(last) = self.toasts.last_mut() + && last.title == kind + { + last.status = status; + last.title = title; + last.body = body; + return; + } + self.notify(status, title, body); + } + /// Returns `true` when the `MapDataHandle` Arc is safe to borrow mutably. /// During save/export, a clone of the Arc is held by the async task, /// making `Arc::get_mut` panic. @@ -439,3 +491,56 @@ impl MapRenderSource for MapEditorState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use gui_widgets::components::toast::Status; + + #[test] + fn test_notify_dedupes_identical_consecutive() { + let mut s = MapDataState::default(); + s.notify(Status::Success, "Saved", "cat1.map"); + s.notify(Status::Success, "Saved", "cat1.map"); + assert_eq!(s.toasts.len(), 1, "identical consecutive toast is deduped"); + + // Same title but different body → new toast. + s.notify(Status::Success, "Saved", "dun04.map"); + assert_eq!(s.toasts.len(), 2); + } + + #[test] + fn test_notify_caps_at_four_dropping_oldest() { + let mut s = MapDataState::default(); + for i in 0..6 { + s.notify(Status::Primary, "Collision", format!("({i},{i})")); + } + assert_eq!(s.toasts.len(), 4, "list capped at 4"); + assert_eq!(s.toasts[0].body, "(2,2)", "oldest toasts dropped first"); + assert_eq!(s.toasts[3].body, "(5,5)"); + } + + #[test] + fn test_notify_replace_replaces_same_kind_last_toast() { + let mut s = MapDataState::default(); + s.notify_replace("Collision", Status::Primary, "Collision", "(1,1)"); + s.notify_replace("Collision", Status::Primary, "Collision", "(2,2)"); + assert_eq!(s.toasts.len(), 1, "same kind replaces instead of stacking"); + assert_eq!(s.toasts[0].body, "(2,2)"); + + // Different kind pushes a new toast. + s.notify_replace("Object ID", Status::Primary, "Object ID", "Obj 7 → (3,3)"); + assert_eq!(s.toasts.len(), 2); + + // Replacing only touches the LAST toast. + s.notify_replace( + "Object ID", + Status::Primary, + "Object ID", + "Erased obj @ (4,4)", + ); + assert_eq!(s.toasts.len(), 2); + assert_eq!(s.toasts[0].body, "(2,2)"); + assert_eq!(s.toasts[1].body, "Erased obj @ (4,4)"); + } +} diff --git a/dispel-gui/src/editors/map_editor/update/conversation.rs b/dispel-gui/src/editors/map_editor/update/conversation.rs index 46f58dfe..7bc29a87 100644 --- a/dispel-gui/src/editors/map_editor/update/conversation.rs +++ b/dispel-gui/src/editors/map_editor/update/conversation.rs @@ -23,7 +23,11 @@ pub fn start(app: &mut App, tab_id: usize, npc_idx: usize) -> Task { let entry_dialog_id = npc.dialog_id; if entry_dialog_id == 0 { if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) { - editor.data.status_msg = Some("NPC has no dialog".into()); + editor.data.notify( + gui_widgets::components::toast::Status::Warning, + "Conversation", + "NPC has no dialog", + ); } return Task::none(); } @@ -122,7 +126,11 @@ pub fn loaded(app: &mut App, tab_id: usize, result: ConversationLoadResult) -> T editor.view.conversation = Some(conv); } Err(err) => { - editor.data.status_msg = Some(format!("Conversation: {err}")); + editor.data.notify( + gui_widgets::components::toast::Status::Danger, + "Error", + format!("Conversation: {err}"), + ); } } } diff --git a/dispel-gui/src/editors/map_editor/update/dialog.rs b/dispel-gui/src/editors/map_editor/update/dialog.rs index 8c3d551d..847f8278 100644 --- a/dispel-gui/src/editors/map_editor/update/dialog.rs +++ b/dispel-gui/src/editors/map_editor/update/dialog.rs @@ -84,12 +84,19 @@ pub fn preview_loaded( dialog_paragraphs: paragraphs, }); } else { - state.data.status_msg = - Some("Dialog preview discarded: NPC selection changed".into()); + state.data.notify( + gui_widgets::components::toast::Status::Warning, + "Dialog", + "Preview discarded: NPC selection changed", + ); } } Err(err) => { - state.data.status_msg = Some(format!("Dialog preview: {err}")); + state.data.notify( + gui_widgets::components::toast::Status::Danger, + "Error", + format!("Dialog preview: {err}"), + ); } } } diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 68182ba2..56e5851f 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -86,8 +86,11 @@ pub fn field_changed( } SelectedEntity::EventTile(tx, ty) => { if !state.data.can_mutate_map_data() { - state.data.status_msg = - Some("Cannot edit events while save/export is in progress".into()); + state.data.notify( + gui_widgets::components::toast::Status::Warning, + "Event", + "Cannot edit while save/export is in progress", + ); entity_mutated = false; } else if let LoadingState::Loaded(ref mut handle) = state.data.loading_state { let map_data = Arc::get_mut(&mut handle.0) diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index 43f47f25..3dd4da3f 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -78,8 +78,11 @@ pub fn toggle_collision_at( ty: i32, ) -> bool { if !state.data.can_mutate_map_data() { - state.data.status_msg = - Some("Cannot edit collision while save/export is in progress".into()); + state.data.notify( + gui_widgets::components::toast::Status::Warning, + "Collision", + "Cannot edit while save/export is in progress", + ); return false; } let LoadingState::Loaded(ref mut handle) = state.data.loading_state else { @@ -97,6 +100,18 @@ pub fn toggle_collision_at( }); state.view.selected_entity = None; state.view.overlay_cache.clear(); + // Replace-in-place: painting a stroke updates one toast, not many. + state.data.notify_replace( + "Collision", + gui_widgets::components::toast::Status::Primary, + "Collision", + format!( + "{} ({},{})", + if old { "Unblocked" } else { "Blocked" }, + tx, + ty + ), + ); true } @@ -110,8 +125,11 @@ pub fn apply_object_id_edit( ty: i32, ) -> bool { if !state.data.can_mutate_map_data() { - state.data.status_msg = - Some("Cannot edit object IDs while save/export is in progress".into()); + state.data.notify( + gui_widgets::components::toast::Status::Warning, + "Object ID", + "Cannot edit while save/export is in progress", + ); return false; } let LoadingState::Loaded(ref mut handle) = state.data.loading_state else { @@ -137,6 +155,18 @@ pub fn apply_object_id_edit( state.view.selected_entity = None; state.view.overlay_cache.clear(); state.view.tile_layer_cache.clear(); + // Replace-in-place: painting a stroke updates one toast, not many. + let body = if erase || new == 0 { + format!("Erased obj @ ({},{})", tx, ty) + } else { + format!("Obj {} → ({},{})", new, tx, ty) + }; + state.data.notify_replace( + "Object ID", + gui_widgets::components::toast::Status::Primary, + "Object ID", + body, + ); true } @@ -237,6 +267,19 @@ pub fn map_loaded( seq_handles.sort_by_key(|s| s.sequence_idx); state.data.sprite_sequence_handles = seq_handles; + let loaded_name = state + .data + .map_path + .as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + state.data.notify( + gui_widgets::components::toast::Status::Success, + "Loaded", + loaded_name, + ); + // Convert decoded internal sprites → Iced Handles. state.data.internal_sprite_handles = decoded_sprites .into_iter() @@ -355,7 +398,11 @@ pub fn tiles_decoded( }; let game_path = app.state.workspace.game_path.clone(); if game_path.is_none() { - state.data.status_msg = Some("No game path set — entity files not loaded".to_string()); + state.data.notify( + gui_widgets::components::toast::Status::Warning, + "Load", + "No game path set — entity files not loaded", + ); } Task::perform( async move { load_entities(&map_path, game_path) }, diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 361cffa1..1f5744e6 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::editors::map_editor::canvas::{find_hovered_element, find_tile_at}; use crate::editors::map_editor::{MapEditorMessage, MapLayer, MapTool, SelectedEntity}; use crate::message::{Message, MessageExt}; +use gui_widgets::components::toast; use iced::Task; mod conversation; @@ -12,9 +13,6 @@ pub use map::resolve_map_filename; mod persistence; mod sprite_export; -/// Duration before a status message is automatically cleared. -const STATUS_DISMISS_SECS: u64 = 3; - pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { match message { // ── Delegated to submodules ─────────────────────────────────────────── @@ -52,7 +50,9 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { Some(p) => p.clone(), None => { if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) { - editor.data.status_msg = Some("Map file path unknown".into()); + editor + .data + .notify(toast::Status::Danger, "Error", "Map file path unknown"); } return Task::none(); } @@ -60,7 +60,9 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { let Some(map_data) = map_data_opt else { if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) { - editor.data.status_msg = Some("Map not loaded".into()); + editor + .data + .notify(toast::Status::Danger, "Error", "Map not loaded"); } return Task::none(); }; @@ -103,10 +105,10 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) { match result { Ok(path) => { - editor.data.status_msg = Some(format!("TMX exported to: {}", path)); + editor.data.notify(toast::Status::Success, "Export", path); } Err(e) => { - editor.data.status_msg = Some(format!("TMX export error: {}", e)); + editor.data.notify(toast::Status::Danger, "Error", e); } } } @@ -245,26 +247,35 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { MapTool::Pan => { // Selection only — never mutates. let clicked = find_hovered_element(state, cx, cy); - state.data.status_msg = Some(match &clicked { - Some(SelectedEntity::CollisionTile(tx, ty)) => { - format!("Collision tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::EventTile(tx, ty)) => { - format!("Event tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::ObjectIdTile(tx, ty)) => { - format!("Object ID tile ({},{}) detected!", tx, ty) - } - Some(SelectedEntity::Monster(i)) => format!("Monster {} detected", i), - Some(SelectedEntity::Npc(i)) => format!("NPC {} detected", i), - Some(SelectedEntity::Extra(i)) => format!("Extra {} detected", i), - Some(SelectedEntity::DrawItem(i)) => { - format!("Draw item {} detected", i) - } - None => { - format!("Clicked at ({:.0},{:.0}) — no tile detected", cx, cy) - } - }); + if let Some(sel) = &clicked { + let (title, body) = match sel { + SelectedEntity::CollisionTile(tx, ty) => { + ("Collision", format!("Tile ({},{}) detected", tx, ty)) + } + SelectedEntity::EventTile(tx, ty) => { + ("Event", format!("Tile ({},{}) detected", tx, ty)) + } + SelectedEntity::ObjectIdTile(tx, ty) => { + ("Object ID", format!("Tile ({},{}) detected", tx, ty)) + } + SelectedEntity::Monster(i) => { + ("Monster", format!("#{} detected", i)) + } + SelectedEntity::Npc(i) => ("NPC", format!("#{} detected", i)), + SelectedEntity::Extra(i) => ("Extra", format!("#{} detected", i)), + SelectedEntity::DrawItem(i) => { + ("Draw Item", format!("#{} detected", i)) + } + }; + // Replace-in-place keeps the stack clean while + // clicking across entities. + state.data.notify_replace( + title, + gui_widgets::components::toast::Status::Primary, + title, + body, + ); + } state.view.selected_entity = clicked; state.view.overlay_cache.clear(); } @@ -284,8 +295,6 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { } MapTool::EventInspect => { if let Some((tx, ty)) = find_tile_at(state, cx, cy) { - state.data.status_msg = - Some(format!("Event tile ({},{}) — inspecting", tx, ty)); state.view.selected_entity = Some(SelectedEntity::EventTile(tx, ty)); state.view.overlay_cache.clear(); } @@ -295,9 +304,11 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { Task::none() } - MapEditorMessage::ClearStatus(tab_id) => { - if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) { - state.data.status_msg = None; + MapEditorMessage::DismissToast(tab_id, index) => { + if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) + && index < state.data.toasts.len() + { + state.data.toasts.remove(index); } Task::none() } @@ -392,13 +403,3 @@ fn set_tab_modified(app: &mut App, tab_id: usize, modified: bool) { tab.modified = modified; } } - -/// Emit a delayed `ClearStatus` message to auto-dismiss the toolbar status text. -fn dismiss_status_after(tab_id: usize) -> Task { - Task::perform( - async move { - tokio::time::sleep(std::time::Duration::from_secs(STATUS_DISMISS_SECS)).await; - }, - move |()| Message::map_editor(MapEditorMessage::ClearStatus(tab_id)), - ) -} diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index ea3a3570..a88ef297 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -5,6 +5,7 @@ use crate::components::loading_state::LoadingState; use crate::message::{Message, MessageExt}; use crate::style; use gui_widgets::components::modal::modal; +use gui_widgets::components::toast; use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; use iced::widget::{ button, canvas, column, container, progress_bar, row, stack, text, text_input, toggler, @@ -310,12 +311,6 @@ pub fn view(app: &App) -> Element<'_, Message> { tmx_btn.on_press(Message::map_editor(MapEditorMessage::ExportTmx(tab_id))); } - let status_text = if let Some(msg) = &state.data.status_msg { - text(msg.as_str()).size(10).style(style::subtle_text) - } else { - text("").size(10).style(style::subtle_text) - }; - // Folded summary replacing the duplicate Handles/Tiles info cells. let summary_text = format!( "{}×{} · {} NPC · {} gtl", @@ -346,7 +341,6 @@ pub fn view(app: &App) -> Element<'_, Message> { export_btn, tmx_btn, text(path_label).size(10).style(style::subtle_text), - status_text, horizontal_space(), match &hint { Some(h) => Element::new(text(h.clone()).size(10).style(style::subtle_text)), @@ -555,16 +549,24 @@ pub fn view(app: &App) -> Element<'_, Message> { .into(); // Wrap in dialog preview modal if open - if let Some(ref preview) = state.view.dialog_preview { + let root: Element<'_, Message> = if let Some(ref preview) = state.view.dialog_preview { modal( base, dialog_preview::view_dialog_preview(state, tab_id, preview), move || Message::map_editor(MapEditorMessage::HideDialogPreview(tab_id)), 0.5, ) + .into() } else { base - } + }; + + // Toast notifications overlay (top-right, auto-dismiss). + toast::Manager::new(root, &state.data.toasts, move |i| { + Message::map_editor(MapEditorMessage::DismissToast(tab_id, i)) + }) + .timeout(3) + .into() } } } From 6a9668d6b2db9ee67e86993d5be11303814b160e Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:25:43 +0200 Subject: [PATCH 21/49] Add Save.ifo gui editor --- dispel-gui/src/dispatch_table.rs | 6 + dispel-gui/src/editor_registry.rs | 5 + dispel-gui/src/editors/mod.rs | 1 + dispel-gui/src/editors/save_ifo/message.rs | 25 + dispel-gui/src/editors/save_ifo/mod.rs | 14 + dispel-gui/src/editors/save_ifo/state.rs | 350 +++++++++++ dispel-gui/src/editors/save_ifo/update.rs | 247 ++++++++ dispel-gui/src/editors/save_ifo/view.rs | 326 +++++++++++ dispel-gui/src/message/editor/mod.rs | 2 + dispel-gui/src/message/ext.rs | 1 + dispel-gui/src/tests/workspace_unit.rs | 15 + dispel-gui/src/update/system.rs | 3 + dispel-gui/src/workspace.rs | 3 + docs/files/Save.ifo.md | 40 +- src/lib.rs | 2 +- src/modding/patchers/mod.rs | 2 + src/modding/patchers/save_ifo.rs | 155 +++++ src/modding/registry.rs | 3 + .../{save_ifo.rs => save_ifo/mod.rs} | 49 +- src/references/save_ifo/swap.rs | 554 ++++++++++++++++++ 20 files changed, 1775 insertions(+), 28 deletions(-) create mode 100644 dispel-gui/src/editors/save_ifo/message.rs create mode 100644 dispel-gui/src/editors/save_ifo/mod.rs create mode 100644 dispel-gui/src/editors/save_ifo/state.rs create mode 100644 dispel-gui/src/editors/save_ifo/update.rs create mode 100644 dispel-gui/src/editors/save_ifo/view.rs create mode 100644 src/modding/patchers/save_ifo.rs rename src/references/{save_ifo.rs => save_ifo/mod.rs} (79%) create mode 100644 src/references/save_ifo/swap.rs diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index 85f8251b..ad7bcf53 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -82,6 +82,7 @@ define_update_dispatch! { (Localization, localization_manager), (HexEditor, hex_wrapper), (SaveFileViewer, save_file_viewer), + (SaveIfo, save_ifo), } define_view_dispatch! { @@ -122,6 +123,7 @@ define_view_dispatch! { (LocalizationManager, localization_manager), (HexEditor, hex_wrapper), (SaveFileViewer, save_file_viewer), + (SaveIfoEditor, save_ifo), } /// Return a `LoadCatalog` task for editors that load from the configured game @@ -169,6 +171,9 @@ pub fn load_catalog_task(et: EditorType) -> Option> { StoreEditor => Some(iced::Task::done(Message::store( crate::editors::store::StoreEditorMessage::LoadCatalog, ))), + SaveIfoEditor => Some(iced::Task::done(Message::save_ifo( + crate::editors::save_ifo::SaveIfoEditorMessage::LoadCatalog, + ))), PartyLevelDbEditor => Some(iced::Task::done(Message::party_level_db( crate::editors::party_level_db::PartyLevelDbEditorMessage::LoadCatalog, ))), @@ -198,6 +203,7 @@ pub fn spreadsheet_nav_msg( EventItemEditor => Message::event_item(event_item::EventItemEditorMessage::Spreadsheet(sm)), MagicEditor => Message::magic(magic::MagicEditorMessage::Spreadsheet(sm)), StoreEditor => return None, // Store editor has a custom layout, no generic spreadsheet + SaveIfoEditor => return None, // SaveIfo editor has a custom layout, no generic spreadsheet NpcIniEditor => Message::npc_ini(npc_ini::NpcIniEditorMessage::Spreadsheet(sm)), NpcRefEditor => Message::npc_ref(npc_ref::NpcRefEditorMessage::Spreadsheet(sm)), MonsterRefEditor => { diff --git a/dispel-gui/src/editor_registry.rs b/dispel-gui/src/editor_registry.rs index a3daca08..d28f47e7 100644 --- a/dispel-gui/src/editor_registry.rs +++ b/dispel-gui/src/editor_registry.rs @@ -22,6 +22,7 @@ use crate::editors::party_ini::PartyIniEditorState; use crate::editors::party_level_db::PartyLevelDbEditorState; use crate::editors::quest_scr::QuestScrEditorState; use crate::editors::save_file_viewer::SaveFileViewerState; +use crate::editors::save_ifo::SaveIfoEditorState; use crate::editors::snf_editor::SnfEditorState; use crate::editors::sprite_editor::SpriteViewerState; use crate::editors::store::StoreEditorState; @@ -74,6 +75,7 @@ pub struct EditorRegistry { pub event_scr_editor: Box, pub wave_ini_editor: Box, pub chdata_editor: Box, + pub save_ifo_editor: Box, pub map_editors: HashMap, pub tileset_editors: HashMap, pub snf_editors: HashMap, @@ -115,6 +117,7 @@ macro_rules! undo_redo_dispatch { // Custom-layout editor (undo/redo without lookups) EditorType::StoreEditor => $self.store_editor.$action(), + EditorType::SaveIfoEditor => $self.save_ifo_editor.$action(), // Sprite editor — uses its own snapshot-based undo/redo EditorType::SpriteViewer => $self.sprite_viewers.get_mut(&$tab_id).map(|viewer| { @@ -327,6 +330,7 @@ impl EditorRegistry { EditorType::PartyRefEditor => Some(self.party_ref_editor.edit_history()), EditorType::PartyIniEditor => Some(self.party_ini_editor.edit_history()), EditorType::StoreEditor => Some(self.store_editor.edit_history()), + EditorType::SaveIfoEditor => Some(self.save_ifo_editor.edit_history()), // Tab-based editors — need a HashMap lookup EditorType::MonsterRefEditor => self @@ -404,6 +408,7 @@ impl EditorRegistry { *self.wave_ini_editor = Default::default(); *self.chdata_editor = Default::default(); *self.event_scr_editor = Default::default(); + *self.save_ifo_editor = Default::default(); // Boxed editors that were missing from reset — bugs found by tests *self.monster_ini_editor = Default::default(); diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs index 6511bfb1..987ed437 100644 --- a/dispel-gui/src/editors/mod.rs +++ b/dispel-gui/src/editors/mod.rs @@ -39,6 +39,7 @@ pub mod party_level_db; pub mod party_ref; pub mod quest_scr; pub mod save_file_viewer; +pub mod save_ifo; pub mod snf_editor; pub mod sprite_editor; pub mod store; diff --git a/dispel-gui/src/editors/save_ifo/message.rs b/dispel-gui/src/editors/save_ifo/message.rs new file mode 100644 index 00000000..88e1c9f9 --- /dev/null +++ b/dispel-gui/src/editors/save_ifo/message.rs @@ -0,0 +1,25 @@ +//! Messages for the Save.ifo editor. + +/// The global tail fields are recorded in the mod changelog under this +/// record id (`Save.ifo` is a single-record file; the hand-written +/// `SaveIfoPatcher` in dispel-core rejects any other id). Slot records +/// themselves are display-only here. +pub const TAIL_RECORD_ID: u32 = 0; + +#[derive(Debug, Clone)] +pub enum SaveIfoEditorMessage { + /// Load `Save.ifo` plus per-slot summaries from the configured game path. + LoadCatalog, + CatalogLoaded(Result<(Vec, dispel_core::SaveIfo), String>), + /// A tail field changed: (field path, new string value). + FieldChanged(String, String), + /// User asked to swap two slots — opens the confirmation modal. + SwapRequested(usize, usize), + /// User confirmed the pending swap (persisted immediately, non-undoable). + SwapConfirm, + /// User dismissed the swap confirmation. + SwapCancel, + SwapDone(Result<(Vec, dispel_core::SaveIfo), String>), + Save, + Saved(Result<(), String>), +} diff --git a/dispel-gui/src/editors/save_ifo/mod.rs b/dispel-gui/src/editors/save_ifo/mod.rs new file mode 100644 index 00000000..9e6b6ac1 --- /dev/null +++ b/dispel-gui/src/editors/save_ifo/mod.rs @@ -0,0 +1,14 @@ +//! Save.ifo editor: save-slot overview with swapping plus global tail fields. +//! +//! Slot rows are display-only; the editable surface is the global tail block. +//! Swaps are confirmed via a modal and persisted immediately (non-undoable). + +mod message; +mod state; +mod update; +mod view; + +pub use message::*; +pub use state::*; +pub use update::*; +pub use view::view; diff --git a/dispel-gui/src/editors/save_ifo/state.rs b/dispel-gui/src/editors/save_ifo/state.rs new file mode 100644 index 00000000..6615467e --- /dev/null +++ b/dispel-gui/src/editors/save_ifo/state.rs @@ -0,0 +1,350 @@ +//! State for the Save.ifo editor. +//! +//! Slot rows are display-only (timestamps are written by the game itself on +//! save); the editable surface is the global tail block plus slot swapping. +//! Tail edits go through [`EditHistory`] so Ctrl+Z/Ctrl+Y work; swaps are +//! confirmed, immediately persisted, and intentionally NOT undoable. + +use crate::components::edit_history::{EditAction, EditHistory}; +use crate::components::generic_editor::UndoRedo; +use crate::components::loading_state::LoadingState; +use dispel_core::{SaveIfo, SlotSummary}; + +/// Loaded editor payload. +#[derive(Debug, Clone)] +pub struct EditorData { + pub summaries: Vec, + pub ifo: SaveIfo, + pub dirty: bool, +} + +/// String buffers for the tail text inputs (source of truth while typing). +#[derive(Debug, Clone, Default)] +pub struct TailBuffers { + pub game_version: String, + pub game_tmp_key: String, + pub map_id: String, + pub reserved: String, + pub payload_counts: [String; 4], +} + +#[derive(Debug, Clone)] +pub struct SaveIfoEditorState { + pub data: Option, + pub loading_state: LoadingState<()>, + pub status_msg: String, + pub tail_buffers: TailBuffers, + pub edit_history: EditHistory, + /// Slots awaiting confirmation: `(a, b)`. + pub pending_swap: Option<(usize, usize)>, +} + +impl Default for SaveIfoEditorState { + fn default() -> Self { + Self { + data: None, + loading_state: LoadingState::Idle, + status_msg: String::new(), + tail_buffers: TailBuffers::default(), + edit_history: EditHistory::default(), + pending_swap: None, + } + } +} + +impl SaveIfoEditorState { + /// Install freshly loaded data and reset transient editing state. + pub fn load_completed(&mut self, summaries: Vec, ifo: SaveIfo) { + self.data = Some(EditorData { + summaries, + ifo, + dirty: false, + }); + self.sync_tail_buffers(); + self.edit_history = EditHistory::default(); + self.pending_swap = None; + } + + /// Splice in post-swap data: slot records and summaries are replaced from + /// the fresh on-disk state, but unsaved tail edits (and their buffers) + /// survive — a swap never touches the global tail. + pub fn apply_swapped(&mut self, summaries: Vec, fresh: SaveIfo) { + let Some(data) = &mut self.data else { + self.load_completed(summaries, fresh); + return; + }; + data.summaries = summaries; + data.ifo.slots = fresh.slots; + // Tail fields and buffers intentionally left as-is (dirty stays true). + } + + /// Refresh tail buffers from the loaded record. + pub fn sync_tail_buffers(&mut self) { + let Some(data) = &self.data else { return }; + let ifo = &data.ifo; + self.tail_buffers = TailBuffers { + game_version: ifo.game_version.to_string(), + game_tmp_key: ifo.game_tmp_key.to_string(), + map_id: ifo.map_id.to_string(), + reserved: ifo.reserved.to_string(), + payload_counts: ifo.payload_counts.map(|v| v.to_string()), + }; + } + + /// Read a tail field as its string representation. `None` for unknown paths. + pub fn field_value(ifo: &SaveIfo, path: &str) -> Option { + match path { + "tail.game_version" => Some(ifo.game_version.to_string()), + "tail.game_tmp_key" => Some(ifo.game_tmp_key.to_string()), + "tail.map_id" => Some(ifo.map_id.to_string()), + "tail.reserved" => Some(ifo.reserved.to_string()), + other => { + let index = other + .strip_prefix("tail.payload_counts.")? + .parse::() + .ok()?; + ifo.payload_counts.get(index).map(|v| v.to_string()) + } + } + } + + /// Parse and apply a tail field. Returns `false` when the path is unknown + /// or the value does not parse (the model is left untouched then). + fn apply_field(ifo: &mut SaveIfo, path: &str, value: &str) -> bool { + match path { + "tail.game_version" => match value.parse::() { + Ok(v) => { + ifo.game_version = v; + true + } + Err(_) => false, + }, + "tail.game_tmp_key" => Self::apply_u32(&mut ifo.game_tmp_key, value), + "tail.map_id" => Self::apply_u32(&mut ifo.map_id, value), + "tail.reserved" => Self::apply_u32(&mut ifo.reserved, value), + other => match other.strip_prefix("tail.payload_counts.") { + Some(index) => match index.parse::() { + Ok(i) if i < ifo.payload_counts.len() => { + Self::apply_u32(&mut ifo.payload_counts[i], value) + } + _ => false, + }, + None => false, + }, + } + } + + fn apply_u32(target: &mut u32, value: &str) -> bool { + match value.parse::() { + Ok(v) => { + *target = v; + true + } + Err(_) => false, + } + } + + /// Commit a tail edit: validates, applies to the model, marks dirty, and + /// records history. Invalid input only updates the status message. + pub fn update_field(&mut self, path: String, value: String) { + let Some(data) = &mut self.data else { + return; + }; + let Some(old_value) = Self::field_value(&data.ifo, &path) else { + return; + }; + if old_value == value { + return; + } + if !Self::apply_field(&mut data.ifo, &path, &value) { + self.status_msg = format!("Invalid value for {path}: '{value}'"); + return; + } + data.dirty = true; + self.set_tail_buffer(&path, value.clone()); + self.edit_history.push(EditAction::FieldChange { + record_idx: 0, + field: path, + old_value, + new_value: value, + }); + } + + fn set_tail_buffer(&mut self, path: &str, value: String) { + match path { + "tail.game_version" => self.tail_buffers.game_version = value, + "tail.game_tmp_key" => self.tail_buffers.game_tmp_key = value, + "tail.map_id" => self.tail_buffers.map_id = value, + "tail.reserved" => self.tail_buffers.reserved = value, + other => { + if let Some(index) = other.strip_prefix("tail.payload_counts.") + && let Ok(i) = index.parse::() + && i < self.tail_buffers.payload_counts.len() + { + self.tail_buffers.payload_counts[i] = value; + } + } + } + } +} + +impl UndoRedo for SaveIfoEditorState { + fn undo(&mut self) -> Option { + let action = self.edit_history.undo()?; + if let EditAction::FieldChange { + ref field, + ref old_value, + .. + } = action + { + if let Some(data) = &mut self.data { + Self::apply_field(&mut data.ifo, field, old_value); + } + self.set_tail_buffer(field, old_value.clone()); + } + Some(format!("Undo: {}", action.display_text())) + } + + fn redo(&mut self) -> Option { + let action = self.edit_history.redo()?; + // The redo action is the inverted undo action: old_value holds the + // value to re-apply (same convention as the store editor). + if let EditAction::FieldChange { + ref field, + ref old_value, + .. + } = action + { + if let Some(data) = &mut self.data { + Self::apply_field(&mut data.ifo, field, old_value); + } + self.set_tail_buffer(field, old_value.clone()); + } + Some(format!("Redo: {}", action.display_text())) + } + + fn can_undo(&self) -> bool { + self.edit_history.can_undo() + } + + fn can_redo(&self) -> bool { + self.edit_history.can_redo() + } + + fn edit_history(&self) -> &EditHistory { + &self.edit_history + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn editor_with_data() -> SaveIfoEditorState { + let mut editor = SaveIfoEditorState::default(); + editor.load_completed( + Vec::new(), + SaveIfo { + game_tmp_key: 6, + map_id: 12, + ..SaveIfo::default() + }, + ); + editor + } + + #[test] + fn test_field_change_updates_model_and_marks_dirty() { + let mut editor = editor_with_data(); + editor.update_field("tail.map_id".into(), "13".into()); + let data = editor.data.as_ref().unwrap(); + assert_eq!(data.ifo.map_id, 13); + assert!(data.dirty); + assert!(editor.edit_history.can_undo()); + } + + #[test] + fn test_field_change_invalid_value_is_rejected() { + let mut editor = editor_with_data(); + editor.update_field("tail.map_id".into(), "abc".into()); + assert_eq!(editor.data.as_ref().unwrap().ifo.map_id, 12); + assert!(!editor.data.as_ref().unwrap().dirty); + assert!(!editor.edit_history.can_undo()); + assert!(editor.status_msg.contains("Invalid value")); + } + + #[test] + fn test_undo_reverts_tail_field_and_buffer() { + let mut editor = editor_with_data(); + editor.update_field("tail.game_tmp_key".into(), "99".into()); + let msg = editor.undo(); + assert!(msg.is_some()); + assert_eq!(editor.data.as_ref().unwrap().ifo.game_tmp_key, 6); + assert_eq!(editor.tail_buffers.game_tmp_key, "6"); + } + + #[test] + fn test_redo_reapplies_tail_field() { + let mut editor = editor_with_data(); + editor.update_field("tail.payload_counts.0".into(), "350".into()); + editor.undo(); + editor.redo(); + assert_eq!(editor.data.as_ref().unwrap().ifo.payload_counts[0], 350); + assert_eq!(editor.tail_buffers.payload_counts[0], "350"); + } + + #[test] + fn test_no_change_skips_history() { + let mut editor = editor_with_data(); + editor.update_field("tail.map_id".into(), "12".into()); + assert!(!editor.edit_history.can_undo()); + } + + #[test] + fn test_load_completed_resets_history_and_dirty() { + let mut editor = editor_with_data(); + editor.update_field("tail.map_id".into(), "20".into()); + editor.load_completed(Vec::new(), SaveIfo::default()); + assert!(!editor.data.as_ref().unwrap().dirty); + assert!(!editor.edit_history.can_undo()); + assert_eq!(editor.tail_buffers.map_id, "0"); + } + + #[test] + fn swapdone_preserves_unsaved_tail_edits() { + let mut editor = editor_with_data(); + editor.update_field("tail.map_id".into(), "99".into()); + + // Fresh on-disk state after a swap: different slots, different tail. + let mut fresh = SaveIfo { + game_tmp_key: 7, + map_id: 55, + ..SaveIfo::default() + }; + fresh.slots[0].flags = [1, 0, 0, 0]; + fresh.slots[0].month = 9; + let summaries = vec![SlotSummary { + index: 0, + occupied: true, + sav_present: true, + month: 9, + day: 1, + hour: 2, + minute: 3, + game_tmp_key: Some(7), + map_id: Some(55), + }]; + + editor.apply_swapped(summaries, fresh); + + let data = editor.data.as_ref().unwrap(); + // Slots + summaries replaced from fresh… + assert_eq!(data.ifo.slots[0].month, 9); + assert_eq!(data.summaries.len(), 1); + assert_eq!(data.summaries[0].month, 9); + // …but unsaved tail edits survive. + assert_eq!(data.ifo.map_id, 99); + assert!(data.dirty); + assert_eq!(editor.tail_buffers.map_id, "99"); + } +} diff --git a/dispel-gui/src/editors/save_ifo/update.rs b/dispel-gui/src/editors/save_ifo/update.rs new file mode 100644 index 00000000..268ea3c8 --- /dev/null +++ b/dispel-gui/src/editors/save_ifo/update.rs @@ -0,0 +1,247 @@ +//! Message handling for the Save.ifo editor. + +use crate::app::App; +use crate::components::loading_state::LoadingState; +use crate::editors::mod_packager::recording; +use crate::editors::save_ifo::message::{SaveIfoEditorMessage, TAIL_RECORD_ID}; +use crate::editors::save_ifo::state::SaveIfoEditorState; +use crate::message::{Message, MessageExt}; +use dispel_core::{Extractor, SaveIfo, SlotSummary, summarize_slots, swap_slots}; +use iced::Task; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type LoadResult = Result<(Vec, SaveIfo), String>; + +/// Keep only the newest [`BACKUPS_TO_KEEP`] `Save.ifo.bak.*` files in `root`. +/// Pruning failures are logged, never fatal — a save must not fail because +/// old backups could not be removed. +const BACKUPS_TO_KEEP: usize = 5; + +fn prune_old_backups(root: &Path) { + let mut backups: Vec = match std::fs::read_dir(root) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("Save.ifo.bak.")) + }) + .collect(), + Err(e) => { + eprintln!("save_ifo: failed to list backups for pruning: {e}"); + return; + } + }; + // Names embed a Unix-timestamp suffix, so lexical order == chronological. + backups.sort(); + while backups.len() > BACKUPS_TO_KEEP { + let oldest = backups.remove(0); + if let Err(e) = std::fs::remove_file(&oldest) { + eprintln!( + "save_ifo: failed to remove old backup {}: {e}", + oldest.display() + ); + } + } +} + +/// Read per-slot summaries plus the `Save.ifo` record from a game root. +fn read_game_data(root: &Path) -> LoadResult { + let summaries = summarize_slots(root).map_err(|e| e.to_string())?; + let mut records = SaveIfo::read_file(&root.join("Save.ifo")).map_err(|e| e.to_string())?; + let ifo = records + .pop() + .ok_or_else(|| "Save.ifo contained no records".to_string())?; + Ok((summaries, ifo)) +} + +pub fn handle(message: SaveIfoEditorMessage, app: &mut App) -> Task { + match message { + SaveIfoEditorMessage::LoadCatalog => { + if app.state.shared_game_path.is_empty() { + app.state.editors.save_ifo_editor.status_msg = + "Please select game path first.".into(); + return Task::none(); + } + if app.state.editors.save_ifo_editor.loading_state == LoadingState::Loading { + app.state.editors.save_ifo_editor.status_msg = + "Operation already in progress.".into(); + return Task::none(); + } + app.state.editors.save_ifo_editor.loading_state = LoadingState::Loading; + app.state.editors.save_ifo_editor.status_msg = "Loading Save.ifo…".into(); + + let root = PathBuf::from(&app.state.shared_game_path); + Task::perform(async move { read_game_data(&root) }, |result| { + Message::save_ifo(SaveIfoEditorMessage::CatalogLoaded(result)) + }) + } + SaveIfoEditorMessage::CatalogLoaded(result) => { + let editor = &mut app.state.editors.save_ifo_editor; + match result { + Ok((summaries, ifo)) => { + let occupied = summaries.iter().filter(|s| s.occupied).count(); + editor.load_completed(summaries, ifo); + editor.loading_state = LoadingState::Loaded(()); + editor.status_msg = format!("Loaded Save.ifo: {occupied} of 6 slots used"); + } + Err(e) => { + editor.loading_state = LoadingState::Failed(e.clone()); + editor.status_msg = format!("Failed to load Save.ifo: {e}"); + } + } + Task::none() + } + SaveIfoEditorMessage::FieldChanged(path, value) => { + let old = app + .state + .editors + .save_ifo_editor + .data + .as_ref() + .and_then(|d| SaveIfoEditorState::field_value(&d.ifo, &path)); + app.state + .editors + .save_ifo_editor + .update_field(path.clone(), value.clone()); + let new_value = app + .state + .editors + .save_ifo_editor + .data + .as_ref() + .and_then(|d| SaveIfoEditorState::field_value(&d.ifo, &path)); + match (old, new_value) { + (Some(old), Some(new)) if old != new => recording::observe_field_change( + app, + "Save.ifo", + TAIL_RECORD_ID, + &path, + old, + new, + ), + _ => Task::none(), + } + } + SaveIfoEditorMessage::SwapRequested(a, b) => { + app.state.editors.save_ifo_editor.pending_swap = Some((a, b)); + Task::none() + } + SaveIfoEditorMessage::SwapCancel => { + app.state.editors.save_ifo_editor.pending_swap = None; + Task::none() + } + SaveIfoEditorMessage::SwapConfirm => { + let editor = &mut app.state.editors.save_ifo_editor; + if editor.loading_state == LoadingState::Loading { + // Prevents a confirmed swap racing an in-flight save on Save.ifo. + editor.status_msg = "Operation already in progress.".into(); + return Task::none(); + } + let Some((a, b)) = editor.pending_swap.take() else { + return Task::none(); + }; + if app.state.shared_game_path.is_empty() { + editor.status_msg = "Please select game path first.".into(); + return Task::none(); + } + editor.loading_state = LoadingState::Loading; + editor.status_msg = format!("Swapping slots {a} and {b}…"); + + let root = PathBuf::from(&app.state.shared_game_path); + Task::perform( + async move { + swap_slots(&root, a, b).map_err(|e| e.to_string())?; + read_game_data(&root) + }, + |result| Message::save_ifo(SaveIfoEditorMessage::SwapDone(result)), + ) + } + SaveIfoEditorMessage::SwapDone(result) => { + let editor = &mut app.state.editors.save_ifo_editor; + editor.pending_swap = None; + match result { + Ok((summaries, fresh)) => { + // A swap only rewrites slot records; keep any unsaved tail + // edits by splicing in just the fresh slot list. + if editor.data.is_some() { + editor.apply_swapped(summaries, fresh); + } else { + editor.load_completed(summaries, fresh); + } + editor.loading_state = LoadingState::Loaded(()); + editor.status_msg = "Slots swapped".into(); + } + Err(e) => { + // Leave loading_state untouched when nothing is loaded — + // there was no in-flight operation to complete. + if editor.data.is_some() { + editor.loading_state = LoadingState::Loaded(()); + } + editor.status_msg = format!("Swap failed: {e}"); + } + } + Task::none() + } + SaveIfoEditorMessage::Save => { + if app.state.shared_game_path.is_empty() { + app.state.editors.save_ifo_editor.status_msg = + "Please select game path first.".into(); + return Task::none(); + } + if app.state.editors.save_ifo_editor.loading_state == LoadingState::Loading { + // Prevents Ctrl+S racing an in-flight swap writing the same file. + app.state.editors.save_ifo_editor.status_msg = + "Operation already in progress.".into(); + return Task::none(); + } + let Some(data) = app.state.editors.save_ifo_editor.data.clone() else { + app.state.editors.save_ifo_editor.status_msg = "Nothing loaded to save.".into(); + return Task::none(); + }; + app.state.editors.save_ifo_editor.status_msg = "Saving Save.ifo…".into(); + app.state.editors.save_ifo_editor.loading_state = LoadingState::Loading; + + let root = PathBuf::from(&app.state.shared_game_path); + Task::perform( + async move { + let path = root.join("Save.ifo"); + if path.exists() { + // Timestamped backup before overwriting. + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs(); + let backup = root.join(format!("Save.ifo.bak.{stamp}")); + std::fs::copy(&path, &backup) + .map_err(|e| format!("Failed to create backup: {e}"))?; + } + Extractor::save_file(std::slice::from_ref(&data.ifo), &path) + .map_err(|e| e.to_string())?; + prune_old_backups(&root); + Ok(()) + }, + |result| Message::save_ifo(SaveIfoEditorMessage::Saved(result)), + ) + } + SaveIfoEditorMessage::Saved(result) => { + let editor = &mut app.state.editors.save_ifo_editor; + editor.loading_state = LoadingState::Loaded(()); + match result { + Ok(()) => { + if let Some(data) = &mut editor.data { + data.dirty = false; + } + editor.sync_tail_buffers(); + editor.status_msg = "Saved Save.ifo".into(); + } + Err(e) => { + editor.status_msg = format!("Error saving Save.ifo: {e}"); + } + } + Task::none() + } + } +} diff --git a/dispel-gui/src/editors/save_ifo/view.rs b/dispel-gui/src/editors/save_ifo/view.rs new file mode 100644 index 00000000..3b380c92 --- /dev/null +++ b/dispel-gui/src/editors/save_ifo/view.rs @@ -0,0 +1,326 @@ +//! View for the Save.ifo editor. + +use super::message::SaveIfoEditorMessage; +use super::state::{EditorData, SaveIfoEditorState}; +use crate::app::App; +use crate::components::loading_state::LoadingState; +use crate::components::utils::horizontal_space; +use crate::message::{Message, MessageExt}; +use crate::style; +use gui_widgets::components::modal::modal; +use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; +use iced::widget::{button, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length}; +use lucide_icons::Icon; + +const SLOT_COUNT: usize = 6; + +pub fn view(app: &App) -> Element<'_, Message> { + let editor = &app.state.editors.save_ifo_editor; + let base: Element<'_, Message> = match &editor.loading_state { + LoadingState::Failed(error) => failed_view(error), + LoadingState::Loading => hint_view("Loading Save.ifo…"), + _ => match &editor.data { + Some(data) => loaded_view(data, editor), + None => hint_view( + "No Save.ifo loaded. Pick a game folder first, then reopen this tab to inspect its save slots.", + ), + }, + }; + + match editor.pending_swap { + Some((a, b)) => modal( + base, + swap_confirm_modal(a, b), + || Message::save_ifo(SaveIfoEditorMessage::SwapCancel), + 0.6, + ), + None => base, + } +} + +fn loaded_view<'a>(data: &'a EditorData, editor: &'a SaveIfoEditorState) -> Element<'a, Message> { + let occupied = data.summaries.iter().filter(|s| s.occupied).count(); + + let header = row![ + column![ + text("Save slots").size(18), + text(format!("{occupied} of {SLOT_COUNT} slots used")) + .size(12) + .style(style::subtle_text), + ] + .spacing(2), + horizontal_space(), + dirty_indicator(data.dirty), + save_button(data.dirty), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center); + + let content = column![ + header, + slot_table(&data.summaries), + container(column![].height(6.0)), + tail_section(editor), + ] + .spacing(14) + .padding(12); + + let body = column![ + scrollable(content).width(Length::Fill).height(Length::Fill), + status_bar(editor), + ] + .height(Length::Fill); + + body.into() +} + +fn dirty_indicator(dirty: bool) -> Element<'static, Message> { + if dirty { + row![ + text(icon_char(Icon::Dot)).font(LUCIDE_FONT).size(12), + text("unsaved changes").style(style::subtle_text), + ] + .spacing(4) + .align_y(iced::alignment::Vertical::Center) + .into() + } else { + horizontal_space().into() + } +} + +fn save_button(dirty: bool) -> Element<'static, Message> { + button(text("Save")) + .padding([4, 14]) + .style(style::commit_button) + .on_press_maybe(dirty.then(|| Message::save_ifo(SaveIfoEditorMessage::Save))) + .into() +} + +fn slot_table(summaries: &[dispel_core::SlotSummary]) -> Element<'_, Message> { + let mut table = column![slot_header_row()].spacing(2); + for summary in summaries.iter().take(SLOT_COUNT) { + table = table.push(slot_row(summary.index, summary)); + } + container(table).into() +} + +fn header_cell(label: &str, width: f32) -> Element<'_, Message> { + container(text(label).style(style::subtle_text)) + .width(width) + .into() +} + +fn slot_header_row() -> Element<'static, Message> { + row![ + header_cell("Slot", 40.0), + header_cell("Used", 50.0), + header_cell("Save file", 90.0), + header_cell("Saved at", 110.0), + header_cell("Tmp key", 70.0), + header_cell("Map id", 70.0), + header_cell("Swap", 80.0), + ] + .spacing(6) + .into() +} + +fn cell(value: String, width: f32) -> Element<'static, Message> { + container(text(value)).width(width).into() +} + +fn icon_cell(icon: Icon, width: f32) -> Element<'static, Message> { + container(text(icon_char(icon)).font(LUCIDE_FONT)) + .width(width) + .into() +} + +fn saved_at_text(summary: &dispel_core::SlotSummary) -> String { + if !summary.occupied { + return "—".into(); + } + format!( + "{:02}/{:02} {:02}:{:02}", + summary.month, summary.day, summary.hour, summary.minute + ) +} + +fn sav_cell(summary: &dispel_core::SlotSummary) -> Element<'static, Message> { + if summary.sav_present { + icon_cell(Icon::Check, 90.0) + } else if summary.occupied { + container( + row![ + text(icon_char(Icon::AlertTriangle)) + .font(LUCIDE_FONT) + .size(11), + text("missing").size(11), + ] + .spacing(3), + ) + .width(90.0) + .into() + } else { + cell("—".into(), 90.0) + } +} + +fn swap_button(icon: Icon, from: usize, to: usize) -> Element<'static, Message> { + let request = (to < SLOT_COUNT && from != to) + .then(|| Message::save_ifo(SaveIfoEditorMessage::SwapRequested(from, to))); + button(text(icon_char(icon)).font(LUCIDE_FONT).size(11)) + .padding([2, 7]) + .style(style::browse_button) + .on_press_maybe(request) + .into() +} + +fn slot_row(index: usize, summary: &dispel_core::SlotSummary) -> Element<'static, Message> { + let opt = |value: Option| value.map_or_else(|| "—".to_string(), |v| v.to_string()); + row![ + cell(index.to_string(), 40.0), + icon_cell( + if summary.occupied { + Icon::CircleDot + } else { + Icon::Circle + }, + 50.0, + ), + sav_cell(summary), + cell(saved_at_text(summary), 110.0), + cell(opt(summary.game_tmp_key), 70.0), + cell(opt(summary.map_id), 70.0), + row![ + swap_button(Icon::ChevronUp, index, index.wrapping_sub(1)), + swap_button(Icon::ChevronDown, index, index + 1), + ] + .spacing(4), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center) + .into() +} + +fn labeled_input<'a>(label: &'a str, value: &'a str, path: &'static str) -> Element<'a, Message> { + row![ + container(text(label).style(style::subtle_text)).width(140.0), + text_input("", value) + .on_input( + move |v| Message::save_ifo(SaveIfoEditorMessage::FieldChanged(path.to_string(), v)) + ) + .width(170.0), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() +} + +fn tail_section(editor: &SaveIfoEditorState) -> Element<'_, Message> { + let buffers = &editor.tail_buffers; + let counts = row![ + container(text("Payload counts").style(style::subtle_text)).width(140.0), + text_input("", &buffers.payload_counts[0]) + .on_input(|v| count_msg(0, v)) + .width(90.0), + text_input("", &buffers.payload_counts[1]) + .on_input(|v| count_msg(1, v)) + .width(90.0), + text_input("", &buffers.payload_counts[2]) + .on_input(|v| count_msg(2, v)) + .width(90.0), + text_input("", &buffers.payload_counts[3]) + .on_input(|v| count_msg(3, v)) + .width(90.0), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center); + + column![ + text("Global state").size(16), + labeled_input("Game version", &buffers.game_version, "tail.game_version"), + labeled_input("Tmp key", &buffers.game_tmp_key, "tail.game_tmp_key"), + labeled_input("Map id", &buffers.map_id, "tail.map_id"), + labeled_input("Reserved", &buffers.reserved, "tail.reserved"), + counts, + ] + .spacing(8) + .into() +} + +fn count_msg(index: usize, value: String) -> Message { + Message::save_ifo(SaveIfoEditorMessage::FieldChanged( + format!("tail.payload_counts.{index}"), + value, + )) +} + +fn status_bar(editor: &SaveIfoEditorState) -> Element<'_, Message> { + container( + row![ + dirty_indicator(editor.data.as_ref().is_some_and(|d| d.dirty)), + horizontal_space(), + text(&editor.status_msg).style(style::subtle_text), + ] + .spacing(8), + ) + .padding([4, 10]) + .width(Length::Fill) + .style(style::status_bar) + .into() +} + +fn swap_confirm_modal(a: usize, b: usize) -> Element<'static, Message> { + let content = column![ + text(format!("Swap save slots {a} and {b}?")).size(16), + text( + "The two .sav files exchange positions immediately and the\n\ + slot metadata in Save.ifo is updated to match.\n\ + This cannot be undone with Ctrl+Z." + ) + .style(style::subtle_text), + row![ + button(text("Cancel")) + .padding([4, 14]) + .style(style::browse_button) + .on_press(Message::save_ifo(SaveIfoEditorMessage::SwapCancel)), + button(text("Swap")) + .padding([4, 14]) + .style(style::commit_button) + .on_press(Message::save_ifo(SaveIfoEditorMessage::SwapConfirm)), + ] + .spacing(8), + ] + .spacing(10); + + container(content) + .padding(16) + .width(430.0) + .style(style::modal_container) + .into() +} + +fn hint_view(message: &str) -> Element<'_, Message> { + container(text(message).style(style::subtle_text)) + .width(Length::Fill) + .center_x(Length::Fill) + .padding(24) + .into() +} + +fn failed_view(error: &str) -> Element<'_, Message> { + container( + column![ + text(format!("Failed to load Save.ifo: {error}")), + button(text("Retry")) + .padding([4, 14]) + .style(style::browse_button) + .on_press(Message::save_ifo(SaveIfoEditorMessage::LoadCatalog)), + ] + .spacing(10), + ) + .width(Length::Fill) + .center_x(Length::Fill) + .padding(24) + .into() +} diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs index 70dfcfac..99df92ae 100644 --- a/dispel-gui/src/message/editor/mod.rs +++ b/dispel-gui/src/message/editor/mod.rs @@ -33,6 +33,7 @@ use crate::editors::party_level_db::PartyLevelDbEditorMessage; use crate::editors::party_ref::PartyRefEditorMessage; use crate::editors::quest_scr::QuestScrEditorMessage; use crate::editors::save_file_viewer::SaveFileViewerMessage; +use crate::editors::save_ifo::SaveIfoEditorMessage; use crate::editors::snf_editor::SnfEditorMessage; use crate::editors::sprite_editor::SpriteViewerMessage; use crate::editors::store::StoreEditorMessage; @@ -80,5 +81,6 @@ pub enum EditorMessage { ModPackager(ModPackagerMessage), Localization(LocalizationMessage), SaveFileViewer(SaveFileViewerMessage), + SaveIfo(SaveIfoEditorMessage), HexEditor(HexEditorMessage), } diff --git a/dispel-gui/src/message/ext.rs b/dispel-gui/src/message/ext.rs index 0d6e8bf9..ab506c8b 100644 --- a/dispel-gui/src/message/ext.rs +++ b/dispel-gui/src/message/ext.rs @@ -39,6 +39,7 @@ define_message_ext! { localization: Localization(crate::editors::localization_manager::LocalizationMessage), hex_editor: HexEditor(hexedit::HexEditorMessage), save_file_viewer: SaveFileViewer(crate::editors::save_file_viewer::SaveFileViewerMessage), + save_ifo: SaveIfo(crate::editors::save_ifo::SaveIfoEditorMessage), } /// Extension trait for building nested editor messages. diff --git a/dispel-gui/src/tests/workspace_unit.rs b/dispel-gui/src/tests/workspace_unit.rs index cc222479..22629ad9 100644 --- a/dispel-gui/src/tests/workspace_unit.rs +++ b/dispel-gui/src/tests/workspace_unit.rs @@ -625,6 +625,21 @@ fn ini_stems_map_to_correct_editor_types() { } } +#[test] +fn ifo_files_map_to_save_ifo_editor() { + let cases = [ + ("Save.ifo", EditorType::SaveIfoEditor), + ("save.ifo", EditorType::SaveIfoEditor), + ]; + for (filename, expected) in cases { + assert_eq!( + EditorType::from_path(Path::new(filename)), + expected, + "failed for {filename}" + ); + } +} + #[test] fn ref_stems_map_to_correct_editor_types() { let cases = [ diff --git a/dispel-gui/src/update/system.rs b/dispel-gui/src/update/system.rs index cfd5a261..a18798c0 100644 --- a/dispel-gui/src/update/system.rs +++ b/dispel-gui/src/update/system.rs @@ -70,6 +70,9 @@ fn save_task_for_editor( // Custom editors with Save support EditorType::StoreEditor => Message::store(StoreEditorMessage::Save), EditorType::PartyLevelDbEditor => Message::party_level_db(PartyLevelDbEditorMessage::Save), + EditorType::SaveIfoEditor => { + Message::save_ifo(crate::editors::save_ifo::SaveIfoEditorMessage::Save) + } // Sprite editor EditorType::SpriteViewer => { diff --git a/dispel-gui/src/workspace.rs b/dispel-gui/src/workspace.rs index 700e9e6e..a106954c 100644 --- a/dispel-gui/src/workspace.rs +++ b/dispel-gui/src/workspace.rs @@ -49,6 +49,8 @@ pub enum EditorType { LocalizationManager, /// Read-only viewer for Dispel .sav save files. SaveFileViewer, + /// Save-slot manager for `Save.ifo`: swap slots and edit global tail fields. + SaveIfoEditor, /// Universal fallback editor for any binary file no dedicated editor /// claims. Also reachable via "Open as Hex" from the file tree. HexEditor, @@ -92,6 +94,7 @@ impl EditorType { "snf" => EditorType::SnfEditor, "map" => EditorType::MapEditor, "sav" => EditorType::SaveFileViewer, + "ifo" => EditorType::SaveIfoEditor, _ => EditorType::HexEditor, } } diff --git a/docs/files/Save.ifo.md b/docs/files/Save.ifo.md index 65805a48..7348c421 100644 --- a/docs/files/Save.ifo.md +++ b/docs/files/Save.ifo.md @@ -13,32 +13,32 @@ from the `game.tmp` append-log. - **Location**: `Save.ifo` (game root directory) - **Encoding**: binary, little-endian -- **Format**: fixed size of 224 bytes — six 32-byte slot records followed by a 32-byte global tail, - addressed as two blocks: bytes 0–191 (slots) and 192–223 (tail). +- **Format**: fixed size of 224 bytes — six 32-byte slot records followed by a 32-byte global tail, addressed as two + blocks: bytes 0–191 (slots) and 192–223 (tail). ### Slot records (offsets 0–191, record *p* at *p* × 32) -| Rel off | Type | Meaning | -|---------|---------|------------------------------------------------------------------------------------------------------| -| +0 | u8[12] | reserved — always zero in known files | -| +12 | u32 | save time: month | -| +16 | u32 | day | -| +20 | u32 | hour | -| +24 | u32 | minute | -| +28 | u8[4] | flags: byte 0 = slot-occupied flag (1 = used), remaining 3 padding | +| Rel off | Type | Meaning | +|---------|--------|--------------------------------------------------------------------| +| +0 | u8[12] | reserved — always zero in known files | +| +12 | u32 | save time: month | +| +16 | u32 | day | +| +20 | u32 | hour | +| +24 | u32 | minute | +| +28 | u8[4] | flags: byte 0 = slot-occupied flag (1 = used), remaining 3 padding | ### Global tail (offsets 192–223) -| Off | Type | Meaning | -|-----|------|--------------------------------------------------------------------------------------------------| -| 192 | f32 | game version — written as 1.4 before every save | -| 196 | u32 | `game.tmp` journal key identifying the current session's payload | -| 200 | u32 | map/world id active at save time | -| 204 | u32 | unknown; zero by default | -| 208 | u32 | payload element count A — snapshot used as size multiplier when traversing the `game.tmp` log | -| 212 | u32 | payload element count B (same role) | -| 216 | u32 | payload element count C (same role) | -| 220 | u32 | payload element count D (same role) | +| Off | Type | Meaning | +|-----|------|-----------------------------------------------------------------------------------------------| +| 192 | f32 | game version — written as 1.4 before every save | +| 196 | u32 | `game.tmp` journal key identifying the current session's payload | +| 200 | u32 | map/world id active at save time | +| 204 | u32 | reserved; zero in known files | +| 208 | u32 | payload element count A — snapshot used as size multiplier when traversing the `game.tmp` log | +| 212 | u32 | payload element count B (same role) | +| 216 | u32 | payload element count C (same role) | +| 220 | u32 | payload element count D (same role) | ## Usage diff --git a/src/lib.rs b/src/lib.rs index d63f44e4..286d7e79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,7 @@ pub use references::{ CharacterIdentity, InventoryData, JournalData, JournalEntry, MapSectionData, MapViewportCell, MapViewportState, PostEventsData, PostMapsData, SaveFile, }, - save_ifo::{SaveIfo, SaveSlotInfo}, + save_ifo::{SavTail, SaveIfo, SaveSlotInfo, SlotSummary, summarize_slots, swap_slots}, store_db::Store, wave_ini::WaveIni, weapons_db::WeaponItem, diff --git a/src/modding/patchers/mod.rs b/src/modding/patchers/mod.rs index 27962a5a..002399fa 100644 --- a/src/modding/patchers/mod.rs +++ b/src/modding/patchers/mod.rs @@ -15,6 +15,7 @@ mod event_script; mod misc_item; mod party_level; mod quest; +mod save_ifo; mod store; // Auto-generated (binary, via #[derive(RecordPatcher)]) @@ -49,4 +50,5 @@ pub use draw_item::DrawItemPatcher; pub use event_script::EventScriptPatcher; pub use party_level::PartyLevelDbPatcher; pub use quest::QuestPatcher; +pub use save_ifo::SaveIfoPatcher; pub use store::StorePatcher; diff --git a/src/modding/patchers/save_ifo.rs b/src/modding/patchers/save_ifo.rs new file mode 100644 index 00000000..612dc260 --- /dev/null +++ b/src/modding/patchers/save_ifo.rs @@ -0,0 +1,155 @@ +//! Field-level patcher for `Save.ifo` (save-slot metadata index). +//! +//! Hand-written because the editable surface is the 32-byte global tail of a +//! single fixed-size record, addressed by dotted field paths +//! (`tail.game_version`, `tail.payload_counts.0`, …) rather than the flat +//! field names the derive macros generate. + +use crate::modding::error::{ModdingError, Result}; +use crate::modding::patcher::{RecordPatcher, unknown_field, wrong_type}; +use crate::modding::value::Value; +use crate::references::save_ifo::SaveIfo; + +pub struct SaveIfoPatcher; + +impl SaveIfoPatcher { + pub const FILENAME: &'static str = "Save.ifo"; + pub const RECORD_NAME: &'static str = "SaveIfo"; +} + +impl RecordPatcher for SaveIfoPatcher { + fn name(&self) -> &'static str { + Self::RECORD_NAME + } + + fn apply_field( + &self, + bytes: &[u8], + record_id: u32, + field: &str, + new: &Value, + ) -> Result> { + if record_id != 0 { + return Err(ModdingError::Malformed(format!( + "{}: record_id {record_id} out of range (single-record file)", + Self::RECORD_NAME + ))); + } + + let mut ifo = SaveIfo::parse(bytes)?; + + match field { + "tail.game_version" => ifo.game_version = parse_f32(field, new)?, + "tail.game_tmp_key" => ifo.game_tmp_key = parse_u32(field, new)?, + "tail.map_id" => ifo.map_id = parse_u32(field, new)?, + "tail.reserved" => ifo.reserved = parse_u32(field, new)?, + other => { + let index = other + .strip_prefix("tail.payload_counts.") + .and_then(|s| s.parse::().ok()) + .filter(|&i| i < ifo.payload_counts.len()) + .ok_or_else(|| unknown_field(Self::RECORD_NAME, other))?; + ifo.payload_counts[index] = parse_u32(field, new)?; + } + } + + let mut out = Vec::with_capacity(bytes.len()); + ifo.write_to(&mut out)?; + Ok(out) + } +} + +fn parse_f32(field: &str, new: &Value) -> Result { + match new { + Value::F64(v) => Ok(*v as f32), + Value::String(s) => s + .trim() + .parse::() + .map_err(|_| wrong_type(SaveIfoPatcher::RECORD_NAME, field, "f32", new)), + _ => Err(wrong_type(SaveIfoPatcher::RECORD_NAME, field, "f32", new)), + } +} + +fn parse_u32(field: &str, new: &Value) -> Result { + match new { + Value::I64(v) => u32::try_from(*v) + .map_err(|_| wrong_type(SaveIfoPatcher::RECORD_NAME, field, "u32", new)), + Value::String(s) => s + .trim() + .parse::() + .map_err(|_| wrong_type(SaveIfoPatcher::RECORD_NAME, field, "u32", new)), + _ => Err(wrong_type(SaveIfoPatcher::RECORD_NAME, field, "u32", new)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::references::save_ifo::{SLOT_COUNT, SaveSlotInfo}; + + fn sample_bytes() -> Vec { + let ifo = SaveIfo { + slots: vec![SaveSlotInfo::default(); SLOT_COUNT], + game_version: 1.4, + game_tmp_key: 6, + map_id: 12, + reserved: 0, + payload_counts: [329, 349, 0, 200], + }; + let mut out = Vec::new(); + ifo.write_to(&mut out).unwrap(); + out + } + + fn apply(bytes: &[u8], field: &str, value: Value) -> Vec { + SaveIfoPatcher.apply_field(bytes, 0, field, &value).unwrap() + } + + #[test] + fn patches_each_tail_field() { + let original = sample_bytes(); + + let patched = apply(&original, "tail.map_id", Value::String("13".into())); + let ifo = SaveIfo::parse(&patched).unwrap(); + assert_eq!(ifo.map_id, 13); + assert_eq!(ifo.game_tmp_key, 6); // untouched + + let patched = apply(&original, "tail.game_version", Value::F64(1.5)); + let ifo = SaveIfo::parse(&patched).unwrap(); + assert!((ifo.game_version - 1.5).abs() < f32::EPSILON); + + let patched = apply(&original, "tail.payload_counts.2", Value::I64(42)); + let ifo = SaveIfo::parse(&patched).unwrap(); + assert_eq!(ifo.payload_counts, [329, 349, 42, 200]); + } + + #[test] + fn rejects_unknown_fields_and_bad_ids() { + let bytes = sample_bytes(); + assert!( + SaveIfoPatcher + .apply_field(&bytes, 0, "slots", &Value::Null) + .is_err() + ); + assert!( + SaveIfoPatcher + .apply_field(&bytes, 0, "tail.nope", &Value::Null) + .is_err() + ); + assert!( + SaveIfoPatcher + .apply_field(&bytes, 0, "tail.payload_counts.9", &Value::I64(1)) + .is_err() + ); + assert!( + SaveIfoPatcher + .apply_field(&bytes, 1, "tail.map_id", &Value::I64(1)) + .is_err() + ); + assert!( + SaveIfoPatcher + .apply_field(&bytes, 0, "tail.map_id", &Value::Bool(true)) + .is_err() + ); + } +} diff --git a/src/modding/registry.rs b/src/modding/registry.rs index 0d41541c..e401e9f3 100644 --- a/src/modding/registry.rs +++ b/src/modding/registry.rs @@ -90,6 +90,9 @@ impl PatcherRegistry { r.register(DrawItemPatcher::FILENAME, Arc::new(DrawItemPatcher)); r.register(QuestPatcher::FILENAME, Arc::new(QuestPatcher)); + // Save-slot metadata index (single fixed-size record, tail fields). + r.register(SaveIfoPatcher::FILENAME, Arc::new(SaveIfoPatcher)); + // Per-file event scripts: every Event*.scr matches. Exact-filename // matches for Quest.scr and Message.scr win over this via the // by_filename map, so they aren't shadowed. diff --git a/src/references/save_ifo.rs b/src/references/save_ifo/mod.rs similarity index 79% rename from src/references/save_ifo.rs rename to src/references/save_ifo/mod.rs index a37e1bba..9d6751d9 100644 --- a/src/references/save_ifo.rs +++ b/src/references/save_ifo/mod.rs @@ -1,5 +1,9 @@ // Save-slot metadata index extraction and serialization for Dispel RPG. +pub mod swap; + +pub use swap::{SavTail, SlotSummary, summarize_slots, swap_slots}; + use crate::references::extractor::Extractor; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use serde::{Deserialize, Serialize}; @@ -13,22 +17,35 @@ pub const SLOT_COUNT: usize = 6; /// Metadata index for the six save slots (`0.sav` … `5.sav`). /// /// Reads file: `Save.ifo` (game root directory) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveIfo { /// Per-slot metadata; always exactly [`SLOT_COUNT`] entries. pub slots: Vec, - /// Game version; observed value 1.4. + /// The game version as a float, e.g. 1.30. pub game_version: f32, /// Key of this session's payload inside the `game.tmp` append-log. pub game_tmp_key: u32, /// Map/world id that was active when the game was last saved. pub map_id: u32, - /// Unknown; observed zero. Reset by the game when starting a new game. - pub unknown: u32, + /// Reserved word; observed zero in all known files. + pub reserved: u32, /// Element counts snapshotted for traversing `game.tmp` payloads. pub payload_counts: [u32; 4], } +impl Default for SaveIfo { + fn default() -> Self { + Self { + slots: vec![SaveSlotInfo::default(); SLOT_COUNT], + game_version: 0.0, + game_tmp_key: 0, + map_id: 0, + reserved: 0, + payload_counts: [0; 4], + } + } +} + /// Metadata for a single save slot. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SaveSlotInfo { @@ -87,7 +104,7 @@ impl SaveIfo { let game_version = cursor.read_f32::()?; let game_tmp_key = cursor.read_u32::()?; let map_id = cursor.read_u32::()?; - let unknown = cursor.read_u32::()?; + let reserved = cursor.read_u32::()?; let mut payload_counts = [0u32; 4]; for count in payload_counts.iter_mut() { *count = cursor.read_u32::()?; @@ -109,7 +126,7 @@ impl SaveIfo { game_version, game_tmp_key, map_id, - unknown, + reserved, payload_counts, }) } @@ -137,7 +154,7 @@ impl SaveIfo { writer.write_f32::(self.game_version)?; writer.write_u32::(self.game_tmp_key)?; writer.write_u32::(self.map_id)?; - writer.write_u32::(self.unknown)?; + writer.write_u32::(self.reserved)?; for count in &self.payload_counts { writer.write_u32::(*count)?; } @@ -162,3 +179,21 @@ impl Extractor for SaveIfo { records[0].write_to(writer) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Both known-good fixtures must round-trip byte-for-byte. + #[test] + fn fixtures_round_trip_byte_for_byte() { + for rel in ["fixtures/Dispel/Save.ifo", "fixtures/kr-save/Save.ifo"] { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{rel}: {e}")); + let parsed = SaveIfo::parse(&original).unwrap_or_else(|e| panic!("{rel}: {e}")); + let mut out = Vec::new(); + parsed.write_to(&mut out).unwrap(); + assert_eq!(out, original, "{rel} did not round-trip"); + } + } +} diff --git a/src/references/save_ifo/swap.rs b/src/references/save_ifo/swap.rs new file mode 100644 index 00000000..36cfcfb7 --- /dev/null +++ b/src/references/save_ifo/swap.rs @@ -0,0 +1,554 @@ +// Save-slot swap and summary operations for Dispel RPG. + +use super::{SLOT_COUNT, SaveIfo, SaveSlotInfo}; +use crate::references::extractor::Extractor; +#[cfg(test)] +use byteorder::WriteBytesExt; +use byteorder::{LittleEndian, ReadBytesExt}; +use serde::{Deserialize, Serialize}; +#[cfg(test)] +use std::io::Write; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +/// Size of the global tail block embedded at the end of each `.sav` payload. +const TAIL_SIZE: usize = 32; + +/// The 32-byte global tail embedded in a `.sav` file (same layout as the +/// `Save.ifo` tail). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SavTail { + pub game_version: f32, + pub game_tmp_key: u32, + pub map_id: u32, + pub reserved: u32, + pub payload_counts: [u32; 4], +} + +impl SavTail { + /// Peek a `.sav` file: read the u32 LE blob size, then seek to + /// `4 + blob_size` and read exactly 32 bytes of tail. + pub fn peek(reader: &mut R) -> std::io::Result { + let blob_size = reader.read_u32::()?; + let tail_offset = 4u64.wrapping_add(blob_size as u64); + reader.seek(SeekFrom::Start(tail_offset))?; + let mut buf = [0u8; TAIL_SIZE]; + reader.read_exact(&mut buf)?; + + let mut cursor = std::io::Cursor::new(&buf[..]); + let game_version = cursor.read_f32::()?; + let game_tmp_key = cursor.read_u32::()?; + let map_id = cursor.read_u32::()?; + let reserved = cursor.read_u32::()?; + let mut payload_counts = [0u32; 4]; + for count in payload_counts.iter_mut() { + *count = cursor.read_u32::()?; + } + Ok(SavTail { + game_version, + game_tmp_key, + map_id, + reserved, + payload_counts, + }) + } + + #[cfg(test)] + fn write_to(&self, writer: &mut W) -> std::io::Result<()> { + writer.write_f32::(self.game_version)?; + writer.write_u32::(self.game_tmp_key)?; + writer.write_u32::(self.map_id)?; + writer.write_u32::(self.reserved)?; + for count in &self.payload_counts { + writer.write_u32::(*count)?; + } + Ok(()) + } +} + +/// Per-slot summary combining `Save.ifo` metadata with `.sav` presence. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotSummary { + pub index: usize, + pub occupied: bool, + pub sav_present: bool, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + /// None when the `.sav` is missing or unreadable. + pub game_tmp_key: Option, + /// None when the `.sav` is missing or unreadable. + pub map_id: Option, +} + +fn ifo_path(game_root: &Path) -> std::path::PathBuf { + game_root.join("Save.ifo") +} + +fn sav_path(game_root: &Path, index: usize) -> std::path::PathBuf { + game_root.join(format!("{}.sav", index)) +} + +fn read_ifo(game_root: &Path) -> std::io::Result { + let mut records = SaveIfo::read_file(&ifo_path(game_root))?; + if records.len() != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Save.ifo must contain exactly one record, got {}", + records.len() + ), + )); + } + Ok(records.remove(0)) +} + +/// Summarize all six save slots: `Save.ifo` metadata plus a peek into each +/// slot's `.sav` payload (when present and readable). +pub fn summarize_slots(game_root: &Path) -> std::io::Result> { + let ifo = read_ifo(game_root)?; + let mut summaries = Vec::with_capacity(SLOT_COUNT); + for (index, slot) in ifo.slots.iter().enumerate() { + let path = sav_path(game_root, index); + let (sav_present, game_tmp_key, map_id) = match std::fs::File::open(&path) { + Err(_) => (false, None, None), + Ok(mut file) => match SavTail::peek(&mut file) { + Ok(tail) => (true, Some(tail.game_tmp_key), Some(tail.map_id)), + // Present but corrupt: report presence without tail values. + Err(_) => (true, None, None), + }, + }; + summaries.push(SlotSummary { + index, + occupied: slot.is_occupied(), + sav_present, + month: slot.month, + day: slot.day, + hour: slot.hour, + minute: slot.minute, + game_tmp_key, + map_id, + }); + } + Ok(summaries) +} + +/// Swap two save slots: exchanges both the whole `.sav` files and their +/// 32-byte records inside `Save.ifo`. The global tail (current-session state) +/// is never touched. +/// +/// File exchange rules: +/// - both exist → contents exchanged crosswise via temp files +/// - only one exists → content moves to the other position; the source record +/// in `Save.ifo` is cleared +/// - neither exists → record swap only +pub fn swap_slots(game_root: &Path, a: usize, b: usize) -> std::io::Result<()> { + if a == b { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot swap slot {} with itself", a), + )); + } + if a >= SLOT_COUNT || b >= SLOT_COUNT { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Slot indices must be below {} (got {}, {})", + SLOT_COUNT, a, b + ), + )); + } + + let mut ifo = read_ifo(game_root)?; + + // Fail-fast: read BOTH .sav files fully before any mutation. + let path_a = sav_path(game_root, a); + let path_b = sav_path(game_root, b); + let data_a = read_sav_if_exists(&path_a)?; + let data_b = read_sav_if_exists(&path_b)?; + + // Validate embedded tails of existing files (fail-fast on corruption). + for (index, data) in [(a, &data_a), (b, &data_b)] { + if let Some(bytes) = data { + let mut cursor = std::io::Cursor::new(bytes); + if let Err(e) = SavTail::peek(&mut cursor) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Corrupt {}.sav: {}", index, e), + )); + } + } + } + + // Swap Save.ifo records (full records incl. reserved + flags). + let tmp_record = ifo.slots[a].clone(); + ifo.slots[a] = ifo.slots[b].clone(); + ifo.slots[b] = tmp_record; + + // Exchange files. + match (&data_a, &data_b) { + (Some(bytes_a), Some(bytes_b)) => { + exchange_files(&path_a, bytes_a, &path_b, bytes_b)?; + } + (Some(bytes_a), None) => { + // Only a exists: move content to b, clear record a. + move_file(&path_a, bytes_a, &path_b)?; + ifo.slots[a] = SaveSlotInfo { + reserved: [0; 12], + month: 0, + day: 0, + hour: 0, + minute: 0, + flags: [0; 4], + }; + } + (None, Some(bytes_b)) => { + // Only b exists: move content to a, clear record b. + move_file(&path_b, bytes_b, &path_a)?; + ifo.slots[b] = SaveSlotInfo { + reserved: [0; 12], + month: 0, + day: 0, + hour: 0, + minute: 0, + flags: [0; 4], + }; + } + (None, None) => {} + } + + Extractor::save_file(&[ifo], &ifo_path(game_root)) +} + +/// Read a `.sav` fully; missing file → None, any other error propagates. +fn read_sav_if_exists(path: &Path) -> std::io::Result>> { + match std::fs::read(path) { + Ok(data) => Ok(Some(data)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Crosswise file exchange via temp files with best-effort rollback from the +/// in-memory buffers on failure. +fn exchange_files( + path_a: &Path, + bytes_a: &[u8], + path_b: &Path, + bytes_b: &[u8], +) -> std::io::Result<()> { + let tmp_a = path_a.with_extension("sav.tmp"); + let tmp_b = path_b.with_extension("sav.tmp"); + + std::fs::write(&tmp_a, bytes_a)?; + if let Err(e) = std::fs::write(&tmp_b, bytes_b) { + let _ = std::fs::remove_file(&tmp_a); + return Err(e); + } + + if let Err(e) = std::fs::rename(&tmp_a, path_b) { + restore(path_a, bytes_a); + restore(path_b, bytes_b); + // Neither rename happened; both temps are still on disk. + let _ = std::fs::remove_file(&tmp_a); + let _ = std::fs::remove_file(&tmp_b); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp_b, path_a) { + restore(path_a, bytes_a); + restore(path_b, bytes_b); + // tmp_a was already renamed onto path_b; only tmp_b leaks here. + let _ = std::fs::remove_file(&tmp_b); + return Err(e); + } + Ok(()) +} + +/// Move `bytes` from `src` to `dst` via a temp file + rename, then remove the +/// source. Restores from the in-memory buffer on failure. +fn move_file(src: &Path, bytes: &[u8], dst: &Path) -> std::io::Result<()> { + let tmp = dst.with_extension("sav.tmp"); + std::fs::write(&tmp, bytes)?; + if let Err(e) = std::fs::rename(&tmp, dst) { + restore(dst, bytes); + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::remove_file(src) { + restore(src, bytes); + return Err(e); + } + Ok(()) +} + +/// Best-effort restore of a file's original contents. Never panics; logs to +/// stderr when the restore itself fails so data loss is at least visible. +fn restore(path: &Path, bytes: &[u8]) { + if let Err(e) = std::fs::write(path, bytes) { + eprintln!("save_ifo swap: failed to restore {}: {e}", path.display()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::references::save_ifo::SaveIfo; + use std::path::PathBuf; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "dispel_swap_test_{}_{}_{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Build synthetic Save.ifo bytes with per-slot timestamps. + fn write_test_ifo(root: &Path, slots: [(u32, u32); SLOT_COUNT]) { + let mut ifo = SaveIfo { + slots: vec![SaveSlotInfo::default(); SLOT_COUNT], + ..Default::default() + }; + for (i, &(month, day)) in slots.iter().enumerate() { + ifo.slots[i] = SaveSlotInfo { + reserved: [0; 12], + month, + day, + hour: i as u32, + minute: 10 + i as u32, + flags: [1, 0, 0, 0], + }; + } + let mut bytes = Vec::new(); + ifo.write_to(&mut bytes).unwrap(); + std::fs::write(ifo_path(root), bytes).unwrap(); + } + + /// Synthetic `.sav`: `[u32 blob_size=0][32-byte tail]` with distinct values. + fn sav_bytes(key: u32, map: u32) -> Vec { + let mut out = Vec::new(); + out.write_u32::(0).unwrap(); // blob size + SavTail { + game_version: 1.4, + game_tmp_key: key, + map_id: map, + reserved: 0, + payload_counts: [key, key * 2, 0, key * 3], + } + .write_to(&mut out) + .unwrap(); + out + } + + fn write_sav(root: &Path, index: usize, bytes: &[u8]) { + std::fs::write(sav_path(root, index), bytes).unwrap(); + } + + fn read_tail(root: &Path, index: usize) -> SavTail { + let mut file = std::fs::File::open(sav_path(root, index)).unwrap(); + SavTail::peek(&mut file).unwrap() + } + + fn read_ifo_bytes(root: &Path) -> SaveIfo { + read_ifo(root).unwrap() + } + + #[test] + fn swap_both_exist() { + let dir = TempDir::new("both"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + write_sav(root, 0, &sav_bytes(100, 1)); + write_sav(root, 2, &sav_bytes(200, 2)); + + swap_slots(root, 0, 2).unwrap(); + + let ifo = read_ifo_bytes(root); + assert_eq!(ifo.slots[0].month, 4); + assert_eq!(ifo.slots[0].day, 12); + assert_eq!(ifo.slots[2].month, 2); + assert_eq!(ifo.slots[2].day, 10); + + // File contents exchanged. + let tail0 = read_tail(root, 0); + assert_eq!(tail0.game_tmp_key, 200); + assert_eq!(tail0.map_id, 2); + let tail2 = read_tail(root, 2); + assert_eq!(tail2.game_tmp_key, 100); + assert_eq!(tail2.map_id, 1); + + // Global tail untouched. + assert_eq!(ifo.game_version, 0.0); + assert_eq!(ifo.game_tmp_key, 0); + assert_eq!(ifo.payload_counts, [0; 4]); + } + + #[test] + fn move_single_save() { + let dir = TempDir::new("single"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + write_sav(root, 3, &sav_bytes(300, 9)); + + swap_slots(root, 1, 3).unwrap(); + + assert!(!sav_path(root, 3).exists()); + let tail1 = read_tail(root, 1); + assert_eq!(tail1.game_tmp_key, 300); + assert_eq!(tail1.map_id, 9); + + let ifo = read_ifo_bytes(root); + // Record 3 (source of the moved content) cleared. + assert!(!ifo.slots[3].is_occupied()); + assert_eq!(ifo.slots[3].month, 0); + assert_eq!(ifo.slots[3].flags, [0; 4]); + // Record 1 (content destination) got old record 3. + assert_eq!(ifo.slots[1].month, 5); + assert_eq!(ifo.slots[1].day, 13); + assert!(ifo.slots[1].is_occupied()); + } + + #[test] + fn swap_empty_slots() { + let dir = TempDir::new("empty"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + + swap_slots(root, 4, 5).unwrap(); + + let ifo = read_ifo_bytes(root); + assert_eq!(ifo.slots[4].month, 7); + assert_eq!(ifo.slots[5].month, 6); + for i in 0..SLOT_COUNT { + assert!(!sav_path(root, i).exists(), "no .sav should be created"); + } + } + + #[test] + fn invalid_indices_rejected() { + let dir = TempDir::new("invalid"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + let before = std::fs::read(ifo_path(root)).unwrap(); + + assert!(swap_slots(root, 0, 6).is_err()); + assert!(swap_slots(root, 2, 2).is_err()); + + let after = std::fs::read(ifo_path(root)).unwrap(); + assert_eq!(before, after); + } + + #[test] + fn corrupt_sav_aborts_without_mutation() { + let dir = TempDir::new("corrupt"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + write_sav(root, 0, &[0u8; 10]); // truncated (< 36 bytes) + write_sav(root, 2, &sav_bytes(200, 2)); + + let ifo_before = std::fs::read(ifo_path(root)).unwrap(); + let sav2_before = std::fs::read(sav_path(root, 2)).unwrap(); + + assert!(swap_slots(root, 0, 2).is_err()); + + assert_eq!(std::fs::read(ifo_path(root)).unwrap(), ifo_before); + assert_eq!(std::fs::read(sav_path(root, 2)).unwrap(), sav2_before); + } + + #[test] + fn summarize_reports_missing_sav() { + let dir = TempDir::new("summary"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + write_sav(root, 0, &sav_bytes(100, 1)); + write_sav(root, 2, &sav_bytes(200, 2)); + // Slots 1, 3, 4, 5 have no .sav. + + let summaries = summarize_slots(root).unwrap(); + assert_eq!(summaries.len(), SLOT_COUNT); + + let s0 = &summaries[0]; + assert!(s0.occupied && s0.sav_present); + assert_eq!(s0.month, 2); + assert_eq!(s0.game_tmp_key, Some(100)); + assert_eq!(s0.map_id, Some(1)); + + let s2 = &summaries[2]; + assert!(s2.sav_present); + assert_eq!(s2.game_tmp_key, Some(200)); + + for &i in &[1usize, 3, 4, 5] { + let s = &summaries[i]; + assert!(s.occupied); + assert!(!s.sav_present); + assert_eq!(s.game_tmp_key, None); + assert_eq!(s.map_id, None); + } + } + + #[test] + fn summarize_reports_corrupt_sav() { + let dir = TempDir::new("corrupt_summary"); + let root = dir.path(); + write_test_ifo(root, [(2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15)]); + write_sav(root, 1, &[0u8; 10]); // truncated (< 36 bytes) + write_sav(root, 2, &sav_bytes(200, 2)); + + let summaries = summarize_slots(root).unwrap(); + + // Corrupt slot: present but no readable tail values. + let s1 = &summaries[1]; + assert!(s1.occupied); + assert!(s1.sav_present); + assert_eq!(s1.game_tmp_key, None); + assert_eq!(s1.map_id, None); + + // Other slots unaffected. + assert_eq!(summaries[2].game_tmp_key, Some(200)); + assert_eq!(summaries[2].map_id, Some(2)); + for &i in &[0usize, 3, 4, 5] { + assert!(!summaries[i].sav_present); + assert_eq!(summaries[i].game_tmp_key, None); + } + } + + #[test] + fn summarize_real_fixture() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/Dispel"); + let summaries = summarize_slots(&root).unwrap(); + assert_eq!(summaries.len(), SLOT_COUNT); + for s in &summaries { + assert!(s.occupied); + } + for s in &summaries[0..3] { + assert!(s.sav_present, "slot {} should have a .sav", s.index); + assert!(s.game_tmp_key.is_some()); + assert!(s.map_id.is_some()); + } + for s in &summaries[3..] { + assert!(!s.sav_present, "slot {} should have no .sav", s.index); + assert!(s.game_tmp_key.is_none()); + } + } +} From e046b965558dc306f2133eb7ec8970dc9d33475f Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:31:01 +0200 Subject: [PATCH 22/49] Add some binary record layouts --- src/references/chdata_db.rs | 4 ++-- src/references/edit_item_db.rs | 12 +++++++++++- src/references/event_item_db.rs | 13 +++++++++++-- src/references/extra_ref.rs | 13 +++++++++++-- src/references/heal_item_db.rs | 13 +++++++++++-- src/references/magic_db.rs | 4 ++-- src/references/misc_item_db.rs | 13 +++++++++++-- src/references/monster_ref.rs | 4 ++-- src/references/npc_ref.rs | 14 ++++++++++++-- src/references/party_ini_db.rs | 14 ++++++++++++-- src/references/weapons_db.rs | 13 +++++++++++-- 11 files changed, 96 insertions(+), 21 deletions(-) diff --git a/src/references/chdata_db.rs b/src/references/chdata_db.rs index 45e3deef..512aae7e 100644 --- a/src/references/chdata_db.rs +++ b/src/references/chdata_db.rs @@ -1,13 +1,13 @@ use std::path::Path; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, RecordPatcher}; +use dispel_macros::{Extractor, RecordLayout, RecordPatcher}; use serde::{Deserialize, Serialize}; /// Stores information about the initial attributes during character creation. /// /// Reads file: `CharacterInGame/ChData.db` -#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher, RecordLayout)] #[extractor(counter_size = 0, property_item_size = 84)] #[patcher(filename = "ChData.db")] pub struct ChData { diff --git a/src/references/edit_item_db.rs b/src/references/edit_item_db.rs index 9b42626b..b011059c 100644 --- a/src/references/edit_item_db.rs +++ b/src/references/edit_item_db.rs @@ -7,6 +7,7 @@ use crate::references::enums::{EditItemEffect, EditItemModification}; use crate::references::extractor::Extractor; use dispel_macros::Extractor; use dispel_macros::Localizable; +use dispel_macros::RecordLayout; use dispel_macros::RecordPatcher; /// EditItem.db - Modifiable Base Items @@ -82,7 +83,16 @@ use dispel_macros::RecordPatcher; /// character equipment. Used for item crafting and stat /// enhancement systems. #[derive( - Debug, Clone, Default, PartialEq, Serialize, Deserialize, Extractor, Localizable, RecordPatcher, + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 268)] #[patcher(filename = "EditItem.db")] diff --git a/src/references/event_item_db.rs b/src/references/event_item_db.rs index a85d4455..060f81f2 100644 --- a/src/references/event_item_db.rs +++ b/src/references/event_item_db.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; /// EventItem.db - Quest/Event Specific Items /// @@ -61,7 +61,16 @@ use dispel_macros::{Extractor, Localizable, RecordPatcher}; /// descriptions. Used for unique items that trigger /// events or are required for quest progression. #[derive( - Debug, Clone, Default, PartialEq, Serialize, Deserialize, Extractor, Localizable, RecordPatcher, + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 240)] #[patcher(filename = "EventItem.db")] diff --git a/src/references/extra_ref.rs b/src/references/extra_ref.rs index a06800c5..61c0bfc6 100644 --- a/src/references/extra_ref.rs +++ b/src/references/extra_ref.rs @@ -4,7 +4,7 @@ use crate::references::enums::{ ActivationEffectId, BooleanFlag, ExtraObjectType, InventoryItem, ItemTypeId, SmallRange0to3, }; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; @@ -12,7 +12,16 @@ use serde::{Deserialize, Serialize}; /// /// Reads file: `ExtraInGame/Extdun01.ref (and other map-specific .ref files)` #[derive( - Debug, Clone, PartialEq, Serialize, Deserialize, Default, Extractor, Localizable, RecordPatcher, + Debug, + Clone, + PartialEq, + Serialize, + Deserialize, + Default, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 184)] #[patcher(extension = "ref", stem_prefix = "ext")] diff --git a/src/references/heal_item_db.rs b/src/references/heal_item_db.rs index fb6ea4d5..1c9c3bf6 100644 --- a/src/references/heal_item_db.rs +++ b/src/references/heal_item_db.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::references::enums::HealItemFlag; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; /// HealItem.db - Consumable Healing Items /// @@ -76,7 +76,16 @@ use dispel_macros::{Extractor, Localizable, RecordPatcher}; /// amounts and cure capabilities. Used for potions, /// scrolls, and other consumable healing items. #[derive( - Debug, Clone, Default, PartialEq, Serialize, Deserialize, Extractor, Localizable, RecordPatcher, + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 252)] #[patcher(filename = "HealItem.db")] diff --git a/src/references/magic_db.rs b/src/references/magic_db.rs index 8c313662..85f01d53 100644 --- a/src/references/magic_db.rs +++ b/src/references/magic_db.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::references::enums::{MagicSpellFlag, MagicType, SpellTargetType}; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, RecordPatcher}; +use dispel_macros::{Extractor, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; @@ -70,7 +70,7 @@ use serde::{Deserialize, Serialize}; /// Defines all magic spells with costs, effects, targeting, /// and visual properties. Used for spell casting system, /// magic combat, and spell learning mechanics. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher, RecordLayout)] #[extractor(property_item_size = 88, counter_size = 0)] #[patcher(filename = "Magic.db")] pub struct MagicSpell { diff --git a/src/references/misc_item_db.rs b/src/references/misc_item_db.rs index 96585f11..e32b4549 100644 --- a/src/references/misc_item_db.rs +++ b/src/references/misc_item_db.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; /// MiscItem.db - Miscellaneous Items /// @@ -44,7 +44,16 @@ use dispel_macros::{Extractor, Localizable, RecordPatcher}; /// +--------------------------------------+ /// ``` #[derive( - Debug, Clone, Default, PartialEq, Serialize, Deserialize, Extractor, Localizable, RecordPatcher, + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 256)] #[patcher(filename = "MiscItem.db")] diff --git a/src/references/monster_ref.rs b/src/references/monster_ref.rs index b1f363ed..8b9f8d70 100644 --- a/src/references/monster_ref.rs +++ b/src/references/monster_ref.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::references::enums::{BooleanFlag, InventoryItem, ItemTypeId, TriStateFlag}; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, RecordPatcher}; +use dispel_macros::{Extractor, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; @@ -65,7 +65,7 @@ use serde::{Deserialize, Serialize}; /// Defines monster placements on specific maps with position, /// event triggers, and loot configurations. Used by game engine /// for monster spawning and encounter design. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, RecordPatcher, RecordLayout)] #[extractor(property_item_size = 56)] #[patcher(extension = "ref", stem_prefix = "mon")] pub struct MonsterRef { diff --git a/src/references/npc_ref.rs b/src/references/npc_ref.rs index 7ba04e38..e9a3a7c0 100644 --- a/src/references/npc_ref.rs +++ b/src/references/npc_ref.rs @@ -5,7 +5,7 @@ use crate::references::enums::{ NpcMovementMode, }; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; @@ -16,7 +16,17 @@ use serde::{Deserialize, Serialize}; /// copies the waypoint block, activation rectangle, movement mode, and /// interaction fields directly into the NPC runtime object. See /// `docs/files/NpcInGame/NpcMapFiles.ref.md` for the complete offset table. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Extractor, Localizable, RecordPatcher)] +#[derive( + Debug, + Clone, + Default, + Serialize, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, +)] #[extractor(property_item_size = 672)] #[patcher(extension = "ref", stem_prefix = "npc")] pub struct NPC { diff --git a/src/references/party_ini_db.rs b/src/references/party_ini_db.rs index fe43331c..2bddc017 100644 --- a/src/references/party_ini_db.rs +++ b/src/references/party_ini_db.rs @@ -1,5 +1,5 @@ use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result as DbResult, params}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -60,7 +60,17 @@ use std::path::Path; /// Defines initial party member configurations /// with names and unknown parameters. Used for /// party initialization and character setup. -#[derive(Debug, Clone, Serialize, Default, Deserialize, Extractor, Localizable, RecordPatcher)] +#[derive( + Debug, + Clone, + Serialize, + Default, + Deserialize, + Extractor, + Localizable, + RecordPatcher, + RecordLayout, +)] #[extractor(counter_size = 0, property_item_size = 28)] #[patcher(filename = "PrtIni.db")] pub struct PartyIniNpc { diff --git a/src/references/weapons_db.rs b/src/references/weapons_db.rs index 1643c671..f5009a08 100644 --- a/src/references/weapons_db.rs +++ b/src/references/weapons_db.rs @@ -1,7 +1,7 @@ use std::path::Path; use crate::references::extractor::Extractor; -use dispel_macros::{Extractor, Localizable, RecordPatcher}; +use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; @@ -89,7 +89,16 @@ use serde::{Deserialize, Serialize}; /// requirements, and durability. Used for equipment /// system, character progression, and combat mechanics. #[derive( - Debug, Clone, Default, PartialEq, Serialize, Deserialize, Localizable, Extractor, RecordPatcher, + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + Localizable, + Extractor, + RecordPatcher, + RecordLayout, )] #[extractor(property_item_size = 284)] #[patcher(filename = "WeaponItem.db")] From 6dc29504c49084b2bc2ce9fdc0791612acb59609 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:07:32 +0200 Subject: [PATCH 23/49] Rename the columns in the database --- src/commands/database.rs | 6 +- src/database.rs | 3 + src/map/database.rs | 8 +- src/queries/create_table_dialogue_scripts.sql | 2 +- src/queries/create_table_edit_items.sql | 2 +- src/queries/create_table_event_actions.sql | 2 +- src/queries/create_table_events.sql | 2 +- src/queries/create_table_extra_refs.sql | 86 +++++++++---------- src/queries/create_table_maps.sql | 2 +- src/queries/create_table_monster_refs.sql | 20 ++--- src/queries/create_table_npc_refs.sql | 36 ++++---- src/queries/create_table_stores.sql | 2 +- src/queries/insert_dialogue_scripts.sql | 2 +- src/queries/insert_edit_item.sql | 2 +- src/queries/insert_event.sql | 2 +- src/queries/insert_event_action.sql | 2 +- src/queries/insert_extra_ref.sql | 86 +++++++++---------- src/queries/insert_map.sql | 2 +- src/queries/insert_monster_ref.sql | 20 ++--- src/queries/insert_npc_ref.sql | 36 ++++---- src/queries/insert_store.sql | 2 +- 21 files changed, 164 insertions(+), 161 deletions(-) diff --git a/src/commands/database.rs b/src/commands/database.rs index 058b0051..3cc03e76 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -223,7 +223,7 @@ fn import_refs(main_path: &Path, conn: &mut Connection) -> Result<(), Box::None, // event_type_id + Option::::None, // event_type Option::::None, // event_filename 0, // counter ])?; @@ -388,7 +388,7 @@ fn import_dialogues_paragraphs( next_id, Option::::None, // required_event_id Option::::None, // next_dialog_to_check - Option::::None, // dialog_type_id + Option::::None, // dialog_type Option::::None, // dialog_owner Option::::None, // dialog_id Option::::None, // next_dialog_id1 @@ -576,7 +576,7 @@ fn import_rest(main_path: &Path, conn: &mut Connection) -> Result<(), Box::None, // required_event_id Option::::None, // next_dialog_to_check - Option::::None, // dialog_type_id + Option::::None, // dialog_type Option::::None, // dialog_owner Option::::None, // dialog_id Option::::None, // next_dialog_id1 diff --git a/src/database.rs b/src/database.rs index dc4920aa..bbd423ed 100644 --- a/src/database.rs +++ b/src/database.rs @@ -112,5 +112,8 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("queries/create_table_sprite_sequences.sql"))?; conn.execute_batch(include_str!("queries/create_table_event_actions.sql"))?; + // Re-enable foreign key enforcement now that the schema is rebuilt. + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + Ok(()) } diff --git a/src/map/database.rs b/src/map/database.rs index 95204841..47f8bdda 100644 --- a/src/map/database.rs +++ b/src/map/database.rs @@ -539,7 +539,7 @@ fn collect_npcs( nr.goto2_filled, nr.goto2_x, nr.goto2_y, nr.goto3_filled, nr.goto3_x, nr.goto3_y, nr.goto4_filled, nr.goto4_x, nr.goto4_y, - ni.sprite_filename, nr.looking_direction \ + ni.sprite_filename, nr.waypoint1_facing_direction \ FROM npc_refs nr \ LEFT JOIN npc_inis ni ON ni.id = nr.npc_ini_id \ WHERE nr.file_path LIKE '%{}'", @@ -562,7 +562,7 @@ fn collect_npcs( row.get::<_, i32>(11)?, // goto4_x row.get::<_, i32>(12)?, // goto4_y row.get::<_, Option>(13)?, // sprite_filename - row.get::<_, i32>(14)?, // looking_direction + row.get::<_, i32>(14)?, // waypoint1_facing_direction )) }) { @@ -611,9 +611,9 @@ fn collect_extras( f = f.replace('\\', "/"); if let Some(name) = f.split('/').next_back() { let q = format!( - "SELECT er.x_pos, er.y_pos, e.sprite_filename, er.rotation, er.object_type, er.closed \ + "SELECT er.map_x, er.map_y, e.sprite_filename, er.direction, er.object_type, er.requires_key \ FROM extra_refs er \ - LEFT JOIN extras e ON e.id = er.ext_id \ + LEFT JOIN extras e ON e.id = er.extra_definition_id \ WHERE er.file_path LIKE '%{}'", name ); diff --git a/src/queries/create_table_dialogue_scripts.sql b/src/queries/create_table_dialogue_scripts.sql index a59844e6..af8495f5 100644 --- a/src/queries/create_table_dialogue_scripts.sql +++ b/src/queries/create_table_dialogue_scripts.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS dialogue_scripts( id INTEGER NOT NULL, required_event_id INTEGER, next_dialog_to_check INTEGER, - dialog_type_id INTEGER, + dialog_type INTEGER, dialog_owner INTEGER, dialog_id INTEGER, next_dialog_id1 INTEGER, diff --git a/src/queries/create_table_edit_items.sql b/src/queries/create_table_edit_items.sql index 52b47dc8..4013c113 100644 --- a/src/queries/create_table_edit_items.sql +++ b/src/queries/create_table_edit_items.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS edit_items ( - id INTEGER, + "index" INTEGER, name TEXT, description TEXT, base_price INTEGER, diff --git a/src/queries/create_table_event_actions.sql b/src/queries/create_table_event_actions.sql index b76b65f9..990c74f2 100644 --- a/src/queries/create_table_event_actions.sql +++ b/src/queries/create_table_event_actions.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS event_actions ( event_id INTEGER, action_order INTEGER, - action_prefix TEXT, + prefix TEXT, function_name TEXT, parameters TEXT, raw_content TEXT diff --git a/src/queries/create_table_events.sql b/src/queries/create_table_events.sql index 136e2707..efc8c420 100644 --- a/src/queries/create_table_events.sql +++ b/src/queries/create_table_events.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS events ( event_id INTEGER PRIMARY KEY, required_event_id INTEGER, - event_type_id INTEGER, + event_type INTEGER, event_filename TEXT, counter INTEGER ) diff --git a/src/queries/create_table_extra_refs.sql b/src/queries/create_table_extra_refs.sql index 25f34111..969a12b1 100644 --- a/src/queries/create_table_extra_refs.sql +++ b/src/queries/create_table_extra_refs.sql @@ -1,53 +1,53 @@ CREATE TABLE IF NOT EXISTS extra_refs ( file_id INTEGER NOT NULL, - id INTEGER, - number_in_file INTEGER, - ext_id INTEGER, - name TEXT, + record_index INTEGER, + map_object_id INTEGER, + extra_definition_id INTEGER, + object_name TEXT, object_type INTEGER, - x_pos INTEGER, - y_pos INTEGER, - rotation INTEGER, - unknown2 BLOB, - unknown3 INTEGER, - closed INTEGER, + map_x INTEGER, + map_y INTEGER, + direction INTEGER, + direction_padding BLOB, + interaction_state INTEGER, + requires_key INTEGER, required_item_id INTEGER, - required_item_type_id INTEGER, + required_item_type INTEGER, required_item_raw INTEGER, - unknown4 INTEGER, - required_item_id2 INTEGER, - required_item_type_id2 INTEGER, + requirement_range_1_padding INTEGER, + required_item2_id INTEGER, + required_item2_type INTEGER, required_item2_raw INTEGER, - unknown6 INTEGER, - unknown7 INTEGER, - unknown8 INTEGER, - unknown9 INTEGER, + requirement_range_2_start INTEGER, + requirement_range_2_end INTEGER, + requirement_range_3_start INTEGER, + requirement_range_3_end INTEGER, gold_amount INTEGER, - item_id INTEGER, - item_type_id INTEGER, - item_raw INTEGER, - unknown10 INTEGER, - item_count INTEGER, - unknown11 INTEGER, - unknown12 INTEGER, - unknown13 INTEGER, - unknown14 BLOB, - event_id INTEGER, - message_id INTEGER, - unknown15 INTEGER, - unknown16 INTEGER, - unknown17 INTEGER, - interactive_element_type INTEGER, - unknown18 BLOB, + loot_item_id INTEGER, + loot_item_type INTEGER, + loot_item_raw INTEGER, + loot_item_padding INTEGER, + loot_item_count INTEGER, + additional_loot_1 INTEGER, + additional_loot_1_count INTEGER, + additional_loot_2 INTEGER, + additional_loot_2_count_and_config BLOB, + interaction_event_id INTEGER, + interaction_message_id INTEGER, + footprint_width INTEGER, + footprint_height INTEGER, + footprint_orientation INTEGER, + interaction_range INTEGER, + interaction_range_padding BLOB, is_quest_element INTEGER, - unknown20 INTEGER, - unknown21 INTEGER, - unknown22 INTEGER, - unknown23 INTEGER, - visibility INTEGER, - unknown24 INTEGER, - unknown25 INTEGER, - unknown26 INTEGER, - unknown27 INTEGER + post_activation_tile_flag INTEGER, + post_activation_footprint_mode INTEGER, + preserve_final_sprite_frame INTEGER, + alternate_render_mode INTEGER, + activation_effect_id INTEGER, + activation_effect_reserved_flag INTEGER, + activation_effect_padding INTEGER, + active_overlay_enabled INTEGER, + map_object_active INTEGER ); \ No newline at end of file diff --git a/src/queries/create_table_maps.sql b/src/queries/create_table_maps.sql index dce0f5cf..2c0940ca 100644 --- a/src/queries/create_table_maps.sql +++ b/src/queries/create_table_maps.sql @@ -5,5 +5,5 @@ CREATE TABLE IF NOT EXISTS maps map_name TEXT, pgp_filename TEXT, dlg_filename TEXT, - is_light BOOLEAN + lighting BOOLEAN ) \ No newline at end of file diff --git a/src/queries/create_table_monster_refs.sql b/src/queries/create_table_monster_refs.sql index 3b73d5ff..a04095e2 100644 --- a/src/queries/create_table_monster_refs.sql +++ b/src/queries/create_table_monster_refs.sql @@ -1,7 +1,7 @@ CREATE TABLE monster_refs ( file_id INTEGER NOT NULL, - id INTEGER, + "index" INTEGER, placement_id INTEGER, monster_db_id INTEGER, map_x INTEGER, @@ -11,15 +11,15 @@ CREATE TABLE monster_refs initial_active_flag INTEGER, ai_type_override INTEGER, event_id_on_kill INTEGER, - loot1_item_id INTEGER, - loot1_item_type INTEGER, - loot1_item_raw INTEGER, - loot2_item_id INTEGER, - loot2_item_type INTEGER, - loot2_item_raw INTEGER, - loot3_item_id INTEGER, - loot3_item_type INTEGER, - loot3_item_raw INTEGER, + loot_item_1_id INTEGER, + loot_item_1_type INTEGER, + loot_item_1_raw INTEGER, + loot_item_2_id INTEGER, + loot_item_2_type INTEGER, + loot_item_2_raw INTEGER, + loot_item_3_id INTEGER, + loot_item_3_type INTEGER, + loot_item_3_raw INTEGER, drop_all_loot INTEGER, force_ai_update INTEGER ); diff --git a/src/queries/create_table_npc_refs.sql b/src/queries/create_table_npc_refs.sql index 69c4057b..599aa05c 100644 --- a/src/queries/create_table_npc_refs.sql +++ b/src/queries/create_table_npc_refs.sql @@ -1,7 +1,7 @@ CREATE TABLE npc_refs ( file_id INTEGER NOT NULL, - row_id INTEGER, + "index" INTEGER, file_record_id INTEGER, npc_ini_id INTEGER, name TEXT, @@ -21,26 +21,26 @@ CREATE TABLE npc_refs goto2_y INTEGER, goto3_y INTEGER, goto4_y INTEGER, - unknown_2 INTEGER, - unknown_3 INTEGER, - unknown_4 INTEGER, - unknown_5 INTEGER, - looking_direction INTEGER, - unknown_6 INTEGER, - unknown_7 INTEGER, - unknown_8 INTEGER, - unknown_9 INTEGER, - unknown_10 INTEGER, - unknown_11 INTEGER, - unknown_12 INTEGER, - unknown_13 INTEGER, - unknown_14 INTEGER, - unknown_15 INTEGER, - unknown_16 INTEGER, + waypoint1_wait_time INTEGER, + waypoint2_wait_time INTEGER, + waypoint3_wait_time INTEGER, + waypoint4_wait_time INTEGER, + waypoint1_facing_direction INTEGER, + waypoint2_facing_direction INTEGER, + waypoint3_facing_direction INTEGER, + waypoint4_facing_direction INTEGER, + waypoint1_reserved INTEGER, + waypoint2_reserved INTEGER, + waypoint3_reserved INTEGER, + waypoint4_reserved INTEGER, + activation_rect_x1 INTEGER, + activation_rect_y1 INTEGER, + activation_rect_x2 INTEGER, + activation_rect_y2 INTEGER, interaction_mode INTEGER, interaction_result_item_id INTEGER, interaction_result_item_type INTEGER, - interaction_result_raw INTEGER, + interaction_result_parameter INTEGER, interaction_range_offset INTEGER, dialog_file_id INTEGER NOT NULL, dialog_id INTEGER, diff --git a/src/queries/create_table_stores.sql b/src/queries/create_table_stores.sql index 97387c82..38849b5c 100644 --- a/src/queries/create_table_stores.sql +++ b/src/queries/create_table_stores.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS stores ( - id INTEGER PRIMARY KEY, + "index" INTEGER PRIMARY KEY, store_name INTEGER, inn_night_cost INTEGER, price_modifier INTEGER, diff --git a/src/queries/insert_dialogue_scripts.sql b/src/queries/insert_dialogue_scripts.sql index a3e3595e..780eac47 100644 --- a/src/queries/insert_dialogue_scripts.sql +++ b/src/queries/insert_dialogue_scripts.sql @@ -3,7 +3,7 @@ INSERT OR REPLACE INTO dialogue_scripts( id, required_event_id, next_dialog_to_check, - dialog_type_id, + dialog_type, dialog_owner, dialog_id, next_dialog_id1, diff --git a/src/queries/insert_edit_item.sql b/src/queries/insert_edit_item.sql index 90ea3bdb..162b5325 100644 --- a/src/queries/insert_edit_item.sql +++ b/src/queries/insert_edit_item.sql @@ -1,4 +1,4 @@ -INSERT OR REPLACE INTO edit_items(id, +INSERT OR REPLACE INTO edit_items("index", name, description, base_price, diff --git a/src/queries/insert_event.sql b/src/queries/insert_event.sql index a0856bbe..55d8554e 100644 --- a/src/queries/insert_event.sql +++ b/src/queries/insert_event.sql @@ -1,6 +1,6 @@ INSERT OR REPLACE INTO events(event_id, required_event_id, - event_type_id, + event_type, event_filename, counter) VALUES (?1, ?2, ?3, ?4, ?5) diff --git a/src/queries/insert_event_action.sql b/src/queries/insert_event_action.sql index f3cd6725..ce86aea5 100644 --- a/src/queries/insert_event_action.sql +++ b/src/queries/insert_event_action.sql @@ -1,6 +1,6 @@ INSERT OR REPLACE INTO event_actions(event_id, action_order, - action_prefix, + prefix, function_name, parameters, raw_content) diff --git a/src/queries/insert_extra_ref.sql b/src/queries/insert_extra_ref.sql index 23af5a91..4e2c7016 100644 --- a/src/queries/insert_extra_ref.sql +++ b/src/queries/insert_extra_ref.sql @@ -1,51 +1,51 @@ INSERT OR REPLACE INTO extra_refs(file_id, - id, - number_in_file, - ext_id, - name, + record_index, + map_object_id, + extra_definition_id, + object_name, object_type, - x_pos, - y_pos, - rotation, - unknown2, - unknown3, - closed, + map_x, + map_y, + direction, + direction_padding, + interaction_state, + requires_key, required_item_id, - required_item_type_id, + required_item_type, required_item_raw, - unknown4, - required_item_id2, - required_item_type_id2, + requirement_range_1_padding, + required_item2_id, + required_item2_type, required_item2_raw, - unknown6, - unknown7, - unknown8, - unknown9, + requirement_range_2_start, + requirement_range_2_end, + requirement_range_3_start, + requirement_range_3_end, gold_amount, - item_id, - item_type_id, - item_raw, - unknown10, - item_count, - unknown11, - unknown12, - unknown13, - unknown14, - event_id, - message_id, - unknown15, - unknown16, - unknown17, - interactive_element_type, - unknown18, + loot_item_id, + loot_item_type, + loot_item_raw, + loot_item_padding, + loot_item_count, + additional_loot_1, + additional_loot_1_count, + additional_loot_2, + additional_loot_2_count_and_config, + interaction_event_id, + interaction_message_id, + footprint_width, + footprint_height, + footprint_orientation, + interaction_range, + interaction_range_padding, is_quest_element, - unknown20, - unknown21, - unknown22, - unknown23, - visibility, - unknown24, - unknown25, - unknown26, - unknown27) + post_activation_tile_flag, + post_activation_footprint_mode, + preserve_final_sprite_frame, + alternate_render_mode, + activation_effect_id, + activation_effect_reserved_flag, + activation_effect_padding, + active_overlay_enabled, + map_object_active) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44, ?45, ?46, ?47, ?48, ?49, ?50); \ No newline at end of file diff --git a/src/queries/insert_map.sql b/src/queries/insert_map.sql index 5657e171..0e03df18 100644 --- a/src/queries/insert_map.sql +++ b/src/queries/insert_map.sql @@ -3,5 +3,5 @@ INSERT OR REPLACE INTO maps(id, map_name, pgp_filename, dlg_filename, - is_light) + lighting) VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ No newline at end of file diff --git a/src/queries/insert_monster_ref.sql b/src/queries/insert_monster_ref.sql index 24de75ac..e8fa0d8e 100644 --- a/src/queries/insert_monster_ref.sql +++ b/src/queries/insert_monster_ref.sql @@ -1,5 +1,5 @@ INSERT OR REPLACE INTO monster_refs(file_id, - id, + "index", placement_id, monster_db_id, map_x, @@ -9,15 +9,15 @@ INSERT OR REPLACE INTO monster_refs(file_id, initial_active_flag, ai_type_override, event_id_on_kill, - loot1_item_id, - loot1_item_type, - loot1_item_raw, - loot2_item_id, - loot2_item_type, - loot2_item_raw, - loot3_item_id, - loot3_item_type, - loot3_item_raw, + loot_item_1_id, + loot_item_1_type, + loot_item_1_raw, + loot_item_2_id, + loot_item_2_type, + loot_item_2_raw, + loot_item_3_id, + loot_item_3_type, + loot_item_3_raw, drop_all_loot, force_ai_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22); diff --git a/src/queries/insert_npc_ref.sql b/src/queries/insert_npc_ref.sql index 40c8b53f..c5371c67 100644 --- a/src/queries/insert_npc_ref.sql +++ b/src/queries/insert_npc_ref.sql @@ -1,5 +1,5 @@ INSERT OR REPLACE INTO npc_refs(file_id, - row_id, + "index", file_record_id, npc_ini_id, name, @@ -19,26 +19,26 @@ INSERT OR REPLACE INTO npc_refs(file_id, goto2_y, goto3_y, goto4_y, - unknown_2, - unknown_3, - unknown_4, - unknown_5, - looking_direction, - unknown_6, - unknown_7, - unknown_8, - unknown_9, - unknown_10, - unknown_11, - unknown_12, - unknown_13, - unknown_14, - unknown_15, - unknown_16, + waypoint1_wait_time, + waypoint2_wait_time, + waypoint3_wait_time, + waypoint4_wait_time, + waypoint1_facing_direction, + waypoint2_facing_direction, + waypoint3_facing_direction, + waypoint4_facing_direction, + waypoint1_reserved, + waypoint2_reserved, + waypoint3_reserved, + waypoint4_reserved, + activation_rect_x1, + activation_rect_y1, + activation_rect_x2, + activation_rect_y2, interaction_mode, interaction_result_item_id, interaction_result_item_type, - interaction_result_raw, + interaction_result_parameter, interaction_range_offset, dialog_file_id, dialog_id, diff --git a/src/queries/insert_store.sql b/src/queries/insert_store.sql index 2b4f25e0..d0dd2512 100644 --- a/src/queries/insert_store.sql +++ b/src/queries/insert_store.sql @@ -1,4 +1,4 @@ -INSERT OR REPLACE INTO stores(id, +INSERT OR REPLACE INTO stores("index", store_name, inn_night_cost, price_modifier, From cb131448fdd442d90bdbb04ac2274aa8586baaf4 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:11:03 +0200 Subject: [PATCH 24/49] Update insert and create table SQL queries --- src/commands/database.rs | 7 ++++ src/database.rs | 2 ++ src/queries/create_table_chdata.sql | 25 +++++++++++++++ src/queries/create_table_event_items.sql | 1 + src/queries/create_table_event_scripts.sql | 1 + src/queries/create_table_party_refs.sql | 2 +- src/queries/create_table_stores.sql | 2 +- src/queries/insert_chdata.sql | 25 +++++++++++++++ src/queries/insert_event_item.sql | 3 +- src/queries/insert_event_script.sql | 4 ++- src/queries/insert_party_ref.sql | 2 +- src/references/chdata_db.rs | 37 ++++++++++++++++++++++ src/references/event_item_db.rs | 8 ++++- src/references/event_scr.rs | 13 ++++++++ 14 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/queries/create_table_chdata.sql create mode 100644 src/queries/insert_chdata.sql diff --git a/src/commands/database.rs b/src/commands/database.rs index 3cc03e76..6a440096 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -2,6 +2,7 @@ use super::Command; use crate::cli::DatabaseCommands; use dispel_core::database::initialize_database; use dispel_core::references::all_map_ini::save_maps; +use dispel_core::references::chdata_db::save_chdatas; use dispel_core::references::dialogue_paragraph::save_dialogue_paragraphs; use dispel_core::references::dialogue_script::save_dialogs; use dispel_core::references::draw_item::save_draw_items; @@ -459,6 +460,12 @@ fn import_databases(main_path: &Path, conn: &mut Connection) -> Result<(), Box Result<()> { )?; let tables = vec![ + "ch_datas", "dialogue_paragraphs", "dialogue_script_files", "dialogue_scripts", @@ -111,6 +112,7 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("queries/create_table_sprite_frames.sql"))?; conn.execute_batch(include_str!("queries/create_table_sprite_sequences.sql"))?; conn.execute_batch(include_str!("queries/create_table_event_actions.sql"))?; + conn.execute_batch(include_str!("queries/create_table_chdata.sql"))?; // Re-enable foreign key enforcement now that the schema is rebuilt. conn.execute_batch("PRAGMA foreign_keys = ON;")?; diff --git a/src/queries/create_table_chdata.sql b/src/queries/create_table_chdata.sql new file mode 100644 index 00000000..c8f79339 --- /dev/null +++ b/src/queries/create_table_chdata.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS ch_datas ( + unused_name TEXT NOT NULL, + warrior_strength INTEGER, + warrior_constitution INTEGER, + warrior_wisdom INTEGER, + warrior_agility INTEGER, + knight_strength INTEGER, + knight_constitution INTEGER, + knight_wisdom INTEGER, + knight_agility INTEGER, + archer_strength INTEGER, + archer_constitution INTEGER, + archer_wisdom INTEGER, + archer_agility INTEGER, + mage_strength INTEGER, + mage_constitution INTEGER, + mage_wisdom INTEGER, + mage_agility INTEGER, + reserved_stat INTEGER, + warrior_offense_bonus INTEGER, + knight_defense_bonus INTEGER, + archer_dodge_bonus INTEGER, + archer_hit_bonus INTEGER, + mage_magic_power_bonus INTEGER +); diff --git a/src/queries/create_table_event_items.sql b/src/queries/create_table_event_items.sql index cbe7e9f2..1a1997de 100644 --- a/src/queries/create_table_event_items.sql +++ b/src/queries/create_table_event_items.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS event_items id INTEGER, name STRING, description STRING, + base_price INTEGER, padding BLOB ) diff --git a/src/queries/create_table_event_scripts.sql b/src/queries/create_table_event_scripts.sql index 6b561131..4ec1fe53 100644 --- a/src/queries/create_table_event_scripts.sql +++ b/src/queries/create_table_event_scripts.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS event_scripts map_content TEXT, chr_content TEXT, npc_content TEXT, + spr_content TEXT, wav_content TEXT ) diff --git a/src/queries/create_table_party_refs.sql b/src/queries/create_table_party_refs.sql index f407628a..4b46e085 100644 --- a/src/queries/create_table_party_refs.sql +++ b/src/queries/create_table_party_refs.sql @@ -8,5 +8,5 @@ CREATE TABLE IF NOT EXISTS party_refs party_dlg_file_id INTEGER NOT NULL, dlg_when_not_in_party INTEGER, dlg_when_in_party INTEGER, - ghost_face_id INTEGER + is_in_party INTEGER ) \ No newline at end of file diff --git a/src/queries/create_table_stores.sql b/src/queries/create_table_stores.sql index 38849b5c..6b8fa3df 100644 --- a/src/queries/create_table_stores.sql +++ b/src/queries/create_table_stores.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS stores ( "index" INTEGER PRIMARY KEY, - store_name INTEGER, + store_name TEXT, inn_night_cost INTEGER, price_modifier INTEGER, invitation TEXT, diff --git a/src/queries/insert_chdata.sql b/src/queries/insert_chdata.sql new file mode 100644 index 00000000..534a6a5f --- /dev/null +++ b/src/queries/insert_chdata.sql @@ -0,0 +1,25 @@ +INSERT OR REPLACE INTO ch_datas ( + unused_name, + warrior_strength, + warrior_constitution, + warrior_wisdom, + warrior_agility, + knight_strength, + knight_constitution, + knight_wisdom, + knight_agility, + archer_strength, + archer_constitution, + archer_wisdom, + archer_agility, + mage_strength, + mage_constitution, + mage_wisdom, + mage_agility, + reserved_stat, + warrior_offense_bonus, + knight_defense_bonus, + archer_dodge_bonus, + archer_hit_bonus, + mage_magic_power_bonus +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23); diff --git a/src/queries/insert_event_item.sql b/src/queries/insert_event_item.sql index 77845f5b..4c0c6364 100644 --- a/src/queries/insert_event_item.sql +++ b/src/queries/insert_event_item.sql @@ -1,5 +1,6 @@ INSERT OR REPLACE INTO event_items(id, name, description, + base_price, padding) -VALUES (?1, ?2, ?3, ?4) +VALUES (?1, ?2, ?3, ?4, ?5) diff --git a/src/queries/insert_event_script.sql b/src/queries/insert_event_script.sql index cfe103d3..28016229 100644 --- a/src/queries/insert_event_script.sql +++ b/src/queries/insert_event_script.sql @@ -3,10 +3,12 @@ INSERT OR REPLACE INTO event_scripts(id, map_content, chr_content, npc_content, + spr_content, wav_content) VALUES (?1, ?2, ?3, ?4, ?5, - ?6) + ?6, + ?7) diff --git a/src/queries/insert_party_ref.sql b/src/queries/insert_party_ref.sql index 0978ace2..9e1fb270 100644 --- a/src/queries/insert_party_ref.sql +++ b/src/queries/insert_party_ref.sql @@ -6,5 +6,5 @@ INSERT OR REPLACE INTO party_refs(id, party_dlg_file_id, dlg_when_not_in_party, dlg_when_in_party, - ghost_face_id) + is_in_party) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \ No newline at end of file diff --git a/src/references/chdata_db.rs b/src/references/chdata_db.rs index 512aae7e..c07e7e9a 100644 --- a/src/references/chdata_db.rs +++ b/src/references/chdata_db.rs @@ -2,6 +2,7 @@ use std::path::Path; use crate::references::extractor::Extractor; use dispel_macros::{Extractor, RecordLayout, RecordPatcher}; +use rusqlite::{Connection, Result, params}; use serde::{Deserialize, Serialize}; /// Stores information about the initial attributes during character creation. @@ -92,6 +93,42 @@ pub fn read_chdata(source_path: &Path) -> std::io::Result> { ChData::read_file(source_path) } +pub fn save_chdatas(conn: &mut Connection, records: &[ChData]) -> Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_chdata.sql"))?; + for record in records { + stmt.execute(params![ + record.unused_name, + record.warrior_strength, + record.warrior_constitution, + record.warrior_wisdom, + record.warrior_agility, + record.knight_strength, + record.knight_constitution, + record.knight_wisdom, + record.knight_agility, + record.archer_strength, + record.archer_constitution, + record.archer_wisdom, + record.archer_agility, + record.mage_strength, + record.mage_constitution, + record.mage_wisdom, + record.mage_agility, + record.reserved_stat, + record.warrior_offense_bonus, + record.knight_defense_bonus, + record.archer_dodge_bonus, + record.archer_hit_bonus, + record.mage_magic_power_bonus, + ])?; + } + } + tx.commit()?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/references/event_item_db.rs b/src/references/event_item_db.rs index 060f81f2..cffa2230 100644 --- a/src/references/event_item_db.rs +++ b/src/references/event_item_db.rs @@ -103,7 +103,13 @@ pub fn save_event_items(conn: &mut Connection, event_items: &[EventItem]) -> Res { let mut stmt = tx.prepare(include_str!("../queries/insert_event_item.sql"))?; for item in event_items { - stmt.execute(params![item.id, item.name, item.description, item.padding])?; + stmt.execute(params![ + item.id, + item.name, + item.description, + item.base_price, + item.padding + ])?; } } tx.commit()?; diff --git a/src/references/event_scr.rs b/src/references/event_scr.rs index 41dc2622..93db0d02 100644 --- a/src/references/event_scr.rs +++ b/src/references/event_scr.rs @@ -594,6 +594,18 @@ pub fn save_event_scripts( let map_content = script.map_content.join("\n"); let chr_content = script.chr_content.join("\n"); let npc_content = script.npc_content.join("\n"); + let spr_content = script + .spr_content + .iter() + .map(|s| { + if s.sprite_file.is_empty() { + s.sprite_alias.clone() + } else { + format!("{}({})", s.sprite_alias, s.sprite_file) + } + }) + .collect::>() + .join("\n"); let wav_content = script.wav_content.join("\n"); // Convert event ID from string to integer @@ -605,6 +617,7 @@ pub fn save_event_scripts( map_content, chr_content, npc_content, + spr_content, wav_content ])?; From 250c00916de7ae709ad6d32b23320e7f0c9fae1b Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:45:06 +0200 Subject: [PATCH 25/49] Render shadows in the game; Add fogdata.dat reader (clean me) --- dispel-gui/src/app.rs | 12 + .../src/components/map_render/render_tiles.rs | 99 ++- .../src/components/map_render/traits.rs | 45 +- .../src/components/map_render/view_state.rs | 3 + dispel-gui/src/dispatch_table.rs | 4 +- dispel-gui/src/editor_registry.rs | 15 + .../src/editors/fog_data/curve_canvas.rs | 334 ++++++++ dispel-gui/src/editors/fog_data/message.rs | 41 + dispel-gui/src/editors/fog_data/mod.rs | 15 + dispel-gui/src/editors/fog_data/state.rs | 325 ++++++++ dispel-gui/src/editors/fog_data/update.rs | 297 +++++++ dispel-gui/src/editors/fog_data/view.rs | 338 ++++++++ .../src/editors/map_editor/canvas/tests.rs | 1 + dispel-gui/src/editors/map_editor/message.rs | 1 + dispel-gui/src/editors/map_editor/state.rs | 8 + .../src/editors/map_editor/update/entity.rs | 1 + .../src/editors/map_editor/update/map.rs | 19 + .../src/editors/map_editor/update/mod.rs | 1 + .../editors/map_editor/update/persistence.rs | 1 - dispel-gui/src/editors/map_editor/view/mod.rs | 8 +- dispel-gui/src/editors/mod.rs | 1 + dispel-gui/src/message/editor/mod.rs | 2 + dispel-gui/src/message/ext.rs | 1 + dispel-gui/src/recording_tests.rs | 132 ++++ dispel-gui/src/tests/capability_crosscheck.rs | 1 + dispel-gui/src/tests/fog_data.rs | 560 ++++++++++++++ dispel-gui/src/tests/map_editor.rs | 106 +++ dispel-gui/src/tests/mod.rs | 1 + dispel-gui/src/tests/save_dispatch.rs | 1 + dispel-gui/src/tests/view_dispatch.rs | 1 + dispel-gui/src/update/system.rs | 5 + dispel-gui/src/workspace.rs | 9 +- docs/files/CROSS_REFERENCES.md | 1 + docs/files/ExtraInGame/fogdata.dat.md | 98 +++ src/cli.rs | 7 +- src/commands/map.rs | 4 +- src/map/fogdata.rs | 278 +++++++ src/map/mod.rs | 66 +- src/map/reader.rs | 43 +- src/map/render.rs | 730 ++++++++++++------ src/map/tileset.rs | 36 + src/map/writer.rs | 1 + src/references/enums.rs | 4 +- 43 files changed, 3376 insertions(+), 280 deletions(-) create mode 100644 dispel-gui/src/editors/fog_data/curve_canvas.rs create mode 100644 dispel-gui/src/editors/fog_data/message.rs create mode 100644 dispel-gui/src/editors/fog_data/mod.rs create mode 100644 dispel-gui/src/editors/fog_data/state.rs create mode 100644 dispel-gui/src/editors/fog_data/update.rs create mode 100644 dispel-gui/src/editors/fog_data/view.rs create mode 100644 dispel-gui/src/tests/fog_data.rs create mode 100644 docs/files/ExtraInGame/fogdata.dat.md create mode 100644 src/map/fogdata.rs diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs index 7859e6dd..607dd482 100644 --- a/dispel-gui/src/app.rs +++ b/dispel-gui/src/app.rs @@ -1,6 +1,7 @@ use crate::components::command_palette::CommandPalette; use crate::components::edit_history::EditHistory; use crate::components::file_tree::FileTree; +use crate::editors::fog_data::FogDataEditorState; use crate::editors::snf_editor::SnfEditorState; use crate::editors::sprite_editor::SpriteViewerState; use crate::editors::tileset::TilesetEditorState; @@ -314,6 +315,17 @@ impl App { } Task::none() } + EditorType::FogDataEditor => { + // 63 KB file — synchronous parse, same as the sprite editor. + if let Some(tab_id) = self.active_tab_id() { + self.state + .editors + .fog_editors + .entry(tab_id) + .or_insert_with(|| FogDataEditorState::load_from_path(path)); + } + Task::none() + } EditorType::HexEditor => { let scripts_dir = self .state diff --git a/dispel-gui/src/components/map_render/render_tiles.rs b/dispel-gui/src/components/map_render/render_tiles.rs index ac273ed9..713e9c91 100644 --- a/dispel-gui/src/components/map_render/render_tiles.rs +++ b/dispel-gui/src/components/map_render/render_tiles.rs @@ -112,7 +112,7 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye items.push(( dispel_core::map::types::internal_sprite_sort_key(spr.sort_y), 1, - spr.x as i32, + spr.x, Item::Sprite(i), )); } @@ -246,6 +246,28 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye bounds, ); } + + // ── Shadow/lighting pass (observed behavior) ───────────────────── + // On Dark maps, level-0 tiles black out and lit tiles fade + // through the fogdata.dat tables. Drawn after world pixels; + // entities on the overlay canvas above stay fully visible, + // like the player character. The GUI fades all channels + // uniformly (canvas has no per-channel multiply); the CLI + // renderer is the pixel-exact reference. + if view.show_shadows + && let Some(fog) = self.state.shadow_data() + { + draw_shadow_layer( + frame, + &map_data.access_ref_words, + fog, + diagonal, + pan_x, + pan_y, + zoom, + bounds, + ); + } }); vec![geometry] @@ -264,3 +286,78 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye } } } + +// ── Shadow/lighting pass ───────────────────────────────────────────────────── + +/// Draws the observed per-tile lighting pass over the world pixels. +/// +/// Level-0 tiles (fog of war) black out; tiles with a light level fade +/// through the fogdata tables, one factor byte per 2×1-pixel pair walking +/// the span mask. The GUI fades all channels uniformly via alpha (canvas +/// has no per-channel multiply) — the CLI renderer is the pixel-exact +/// reference. +#[allow(clippy::too_many_arguments)] +fn draw_shadow_layer( + frame: &mut canvas::Frame, + words: &std::collections::HashMap<(i32, i32), u32>, + fog: &dispel_core::map::render::FogData, + diagonal: i32, + pan_x: f32, + pan_y: f32, + zoom: f32, + bounds: Rectangle, +) { + use dispel_core::map::render::SHADOW_SPANS; + + let tile_w = TILE_W * zoom; + let tile_h = TILE_H * zoom; + let pair_w = 2.0 * zoom; + let row_h = zoom; + + for (&(tx, ty), &word) in words { + let level = (word >> 15) & 0x7FFF; + if level >= 200 { + continue; + } + + let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); + if !is_visible(px, py, tile_w, tile_h, bounds) { + continue; + } + + if level == 0 { + // Fog of war: solid black diamond covering the tile. + let cx = px + tile_w * 0.5; + let cy = py + tile_h * 0.5; + let hw = tile_w * 0.5; + let hh = tile_h * 0.5; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(cx, cy - hh)); + b.line_to(Point::new(cx + hw, cy)); + b.line_to(Point::new(cx, cy + hh)); + b.line_to(Point::new(cx - hw, cy)); + b.close(); + }); + frame.fill(&path, Color::BLACK); + continue; + } + + // Lit tile: one overlay rect per pixel pair, alpha = 1 − f/32. + let mut pair = 0usize; + for (row, &(start, width)) in SHADOW_SPANS.iter().enumerate() { + let y = py + row as f32 * row_h; + for p in 0..(width / 2) { + let x = px + (start + p * 2) as f32 * zoom; + if is_visible(x, y, pair_w, row_h, bounds) { + let f = f32::from(fog.factor(level, pair)) / 32.0; + frame.fill_rectangle( + Point::new(x, y), + Size::new(pair_w, row_h), + Color::from_rgba(0.0, 0.0, 0.0, 1.0 - f), + ); + } + pair += 1; + } + } + } +} diff --git a/dispel-gui/src/components/map_render/traits.rs b/dispel-gui/src/components/map_render/traits.rs index 3eaee6cd..ad917cac 100644 --- a/dispel-gui/src/components/map_render/traits.rs +++ b/dispel-gui/src/components/map_render/traits.rs @@ -30,6 +30,31 @@ impl EntityKind { } } +pub struct EntityRenderData<'a> { + pub tile_x: i32, + pub tile_y: i32, + pub sort_key: i32, + pub sprite: Option<&'a EntitySpriteHandle>, + pub kind: EntityKind, + pub visible: bool, +} + +pub trait MapRenderSource { + /// Lighting fade tables for the shadow pass; `None` disables shadows. + fn shadow_data(&self) -> Option<&std::sync::Arc> { + None + } + + fn map_data(&self) -> Option<&MapDataHandle>; + fn gtl_handles(&self) -> &HashMap; + fn btl_handles(&self) -> &HashMap; + fn tiles_ready(&self) -> bool; + fn view(&self) -> &MapViewState; + fn internal_sprite_handles(&self) -> &[InternalSpriteHandle]; + fn entity_count(&self) -> usize; + fn entity_data(&self, idx: usize) -> Option>; +} + #[cfg(test)] mod tests { use super::EntityKind; @@ -67,23 +92,3 @@ mod tests { assert!(order[0].type_order() > 1); } } - -pub struct EntityRenderData<'a> { - pub tile_x: i32, - pub tile_y: i32, - pub sort_key: i32, - pub sprite: Option<&'a EntitySpriteHandle>, - pub kind: EntityKind, - pub visible: bool, -} - -pub trait MapRenderSource { - fn map_data(&self) -> Option<&MapDataHandle>; - fn gtl_handles(&self) -> &HashMap; - fn btl_handles(&self) -> &HashMap; - fn tiles_ready(&self) -> bool; - fn view(&self) -> &MapViewState; - fn internal_sprite_handles(&self) -> &[InternalSpriteHandle]; - fn entity_count(&self) -> usize; - fn entity_data(&self, idx: usize) -> Option>; -} diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs index e20efd0e..7d971648 100644 --- a/dispel-gui/src/components/map_render/view_state.rs +++ b/dispel-gui/src/components/map_render/view_state.rs @@ -15,6 +15,8 @@ pub struct MapViewState { pub show_ground: bool, pub show_buildings: bool, pub show_roofs: bool, + /// Observed shadow/lighting pass (fog-of-war on Dark maps). + pub show_shadows: bool, pub show_internal_sprites: bool, pub show_collisions: bool, pub show_events: bool, @@ -69,6 +71,7 @@ impl Default for MapViewState { show_ground: true, show_buildings: true, show_roofs: true, + show_shadows: true, show_internal_sprites: true, show_collisions: false, show_events: false, diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index ad7bcf53..43b969ff 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -1,6 +1,6 @@ //! Generated editor dispatch table. //! -//! Collapses the 37-arm message dispatch and the 35-arm view dispatch +//! Collapses the 39-arm message dispatch and the 38-arm view dispatch //! into a single macro declaration. Two editors are handled specially //! outside this table: //! - `DbViewer` — no EditorMessage variant; view is `App::view_db_viewer()` @@ -83,6 +83,7 @@ define_update_dispatch! { (HexEditor, hex_wrapper), (SaveFileViewer, save_file_viewer), (SaveIfo, save_ifo), + (FogData, fog_data), } define_view_dispatch! { @@ -124,6 +125,7 @@ define_view_dispatch! { (HexEditor, hex_wrapper), (SaveFileViewer, save_file_viewer), (SaveIfoEditor, save_ifo), + (FogDataEditor, fog_data), } /// Return a `LoadCatalog` task for editors that load from the configured game diff --git a/dispel-gui/src/editor_registry.rs b/dispel-gui/src/editor_registry.rs index d28f47e7..cdbe33d8 100644 --- a/dispel-gui/src/editor_registry.rs +++ b/dispel-gui/src/editor_registry.rs @@ -12,6 +12,7 @@ use crate::editors::event_ini::EventIniEditorState; use crate::editors::event_npc_ref::EventNpcRefEditorState; use crate::editors::event_scr::EventScriptEditorState; use crate::editors::extra_ini::ExtraIniEditorState; +use crate::editors::fog_data::FogDataEditorState; use crate::editors::magic::MagicEditorState; use crate::editors::map_editor::MapEditorState; use crate::editors::map_ini::MapIniEditorState; @@ -83,6 +84,7 @@ pub struct EditorRegistry { pub mod_packager_editor: mod_packager::ModPackagerState, pub localization_manager: localization_manager::LocalizationManagerState, pub save_file_viewers: HashMap, + pub fog_editors: HashMap, } /// Macro: dispatch `undo` or `redo` to the correct editor field. @@ -125,6 +127,16 @@ macro_rules! undo_redo_dispatch { String::new() // no status message needed }), + // Fog data editor — snapshot-based undo/redo; reports whether the + // operation did anything so the status bar can say "Nothing to undo". + EditorType::FogDataEditor => $self.fog_editors.get_mut(&$tab_id).and_then(|editor| { + if editor.$action() { + Some(String::new()) + } else { + None + } + }), + // Tab-based editors (MultiFileEditorState via TabbedEditor) EditorType::MonsterRefEditor => $self .monster_ref_editor @@ -177,6 +189,7 @@ impl EditorRegistry { self.map_editors.remove(&tab_id); self.hex_editors.remove(&tab_id); self.save_file_viewers.remove(&tab_id); + self.fog_editors.remove(&tab_id); } /// Clear editors for every tab. Use when the workspace is about to lose @@ -198,6 +211,7 @@ impl EditorRegistry { self.map_editors.clear(); self.hex_editors.clear(); self.save_file_viewers.clear(); + self.fog_editors.clear(); } /// Stop SNF audio playback on every open SNF editor. @@ -384,6 +398,7 @@ impl EditorRegistry { self.snf_editors.clear(); self.hex_editors.clear(); self.save_file_viewers.clear(); + self.fog_editors.clear(); // Boxed editors — reset to default *self.weapon_editor = Default::default(); diff --git a/dispel-gui/src/editors/fog_data/curve_canvas.rs b/dispel-gui/src/editors/fog_data/curve_canvas.rs new file mode 100644 index 00000000..0250dbf9 --- /dev/null +++ b/dispel-gui/src/editors/fog_data/curve_canvas.rs @@ -0,0 +1,334 @@ +//! The fog-curve canvas: draws one level's 512-sample flicker curve over a +//! subtle grid and turns pointer input into pair selection / factor painting. +//! +//! Mapping: x → pixel-pair index (`0..512`), y → brightness factor +//! (`0..=31`, inverted so brighter is up). + +use crate::editors::fog_data::message::FogDataMessage; +use crate::message::MessageExt; +use dispel_core::map::fogdata::{MAX_FACTOR, ROW_LEN}; +use iced::widget::canvas::{self, Action, Frame, Geometry, Path, Stroke, Text}; +use iced::{Color, Element, Event, Point, Rectangle, Renderer, Size, mouse}; + +// ── Palette ─────────────────────────────────────────────────────────────────── + +/// Canvas backdrop — darker than the app background, consistent with the +/// map/hex canvases. +const BG: Color = Color::from_rgb(0.063, 0.063, 0.078); +/// Horizontal grid lines at factor levels. +const GRID_VALUE: Color = Color { + a: 0.14, + ..Color::from_rgb(0.85, 0.78, 0.60) +}; +/// The 0 and MAX_FACTOR boundary lines are slightly stronger. +const GRID_EDGE: Color = Color { + a: 0.28, + ..Color::from_rgb(0.85, 0.78, 0.60) +}; +/// Vertical grid lines every 64 pairs. +const GRID_PAIR: Color = Color { + a: 0.08, + ..Color::from_rgb(0.85, 0.78, 0.60) +}; +/// The flicker curve itself — warm gold, echoing the Medieval accent. +const CURVE: Color = Color::from_rgb(0.891, 0.769, 0.412); +/// Translucent area under the curve. +const CURVE_FILL: Color = Color { a: 0.10, ..CURVE }; +/// Selected-pair marker + hairline. +const SELECTION: Color = Color::from_rgb(0.918, 0.878, 0.784); +const SELECTION_HAIRLINE: Color = Color { + a: 0.30, + ..SELECTION +}; +/// Hover crosshair (cursor position only — costs no messages). +const HOVER: Color = Color { + a: 0.35, + ..Color::from_rgb(1.0, 1.0, 1.0) +}; +const LABEL: Color = Color { + a: 0.45, + ..Color::from_rgb(0.85, 0.78, 0.60) +}; + +/// Canvas program state: whether the left button is currently held inside +/// this widget (i.e. a paint stroke is in progress). +#[derive(Debug, Default, Clone, Copy)] +pub struct CurveCanvasState { + painting: bool, +} + +/// Borrowed view of the editor data, used as the canvas [`canvas::Program`]. +pub struct FogCurveCanvas<'a> { + pub tab_id: usize, + /// The selected level's 512 samples. + pub row: &'a [u8], + pub selected_pair: usize, +} + +impl<'a> FogCurveCanvas<'a> { + pub fn into_element(self) -> Element<'a, crate::message::Message> { + iced::widget::Canvas::new(self) + .width(iced::Fill) + .height(iced::Fill) + .into() + } +} + +/// Pixel x → pair index (`0..ROW_LEN`). +fn pair_from_x(x: f32, width: f32) -> usize { + if width <= 0.0 { + return 0; + } + let fraction = (x / width).clamp(0.0, 1.0); + ((fraction * (ROW_LEN - 1) as f32).round() as usize).min(ROW_LEN - 1) +} + +/// Pixel y → quantized factor (`0..=MAX_FACTOR`, y grows downward). +fn value_from_y(y: f32, height: f32) -> u8 { + if height <= 0.0 { + return 0; + } + let fraction = 1.0 - (y / height).clamp(0.0, 1.0); + ((fraction * MAX_FACTOR as f32).round() as u8).min(MAX_FACTOR) +} + +/// Pair index → sample x coordinate. +fn x_of_pair(pair: usize, width: f32) -> f32 { + (pair as f32 / (ROW_LEN - 1) as f32) * width +} + +/// Factor value → sample y coordinate (brighter is up). +fn y_of_value(value: u8, height: f32) -> f32 { + (1.0 - value as f32 / MAX_FACTOR as f32) * height +} + +impl<'a> canvas::Program for FogCurveCanvas<'a> { + type State = CurveCanvasState; + + fn update( + &self, + interaction: &mut Self::State, + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + use mouse::{Button, Event as MouseEvent}; + + match event { + // Stroke start: select the pair and paint it immediately. + // (Painting implies selection — one message, no `and_publish`.) + Event::Mouse(MouseEvent::ButtonPressed(Button::Left)) => { + let pos = cursor.position_in(bounds)?; + interaction.painting = true; + let pair = pair_from_x(pos.x, bounds.width); + let value = value_from_y(pos.y, bounds.height); + Some( + Action::publish(crate::message::Message::fog_data( + FogDataMessage::FactorPainted(self.tab_id, pair, value), + )) + .and_capture(), + ) + } + // Dragging paints along the path. + Event::Mouse(MouseEvent::CursorMoved { .. }) if interaction.painting => { + let pos = cursor.position_in(bounds)?; + let pair = pair_from_x(pos.x, bounds.width); + let value = value_from_y(pos.y, bounds.height); + Some( + Action::publish(crate::message::Message::fog_data( + FogDataMessage::FactorPainted(self.tab_id, pair, value), + )) + .and_capture(), + ) + } + // Stroke end: commit the undo snapshot. + Event::Mouse(MouseEvent::ButtonReleased(Button::Left)) => { + if !interaction.painting { + return None; + } + interaction.painting = false; + Some( + Action::publish(crate::message::Message::fog_data( + FogDataMessage::StrokeEnded(self.tab_id), + )) + .and_capture(), + ) + } + _ => None, + } + } + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &iced::Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec { + let width = bounds.width; + let height = bounds.height; + let mut frame = Frame::new(renderer, bounds.size()); + + // Backdrop + frame.fill_rectangle(Point::ORIGIN, Size::new(width, height), BG); + + // Horizontal grid at meaningful factor levels (0/8/16/24/31). + for &level in &[0u8, 8, 16, 24] { + let y = y_of_value(level, height); + stroke_line( + &mut frame, + Point::new(0.0, y), + Point::new(width, y), + GRID_VALUE, + 1.0, + ); + } + let top_y = y_of_value(MAX_FACTOR, height); + stroke_line( + &mut frame, + Point::new(0.0, top_y), + Point::new(width, top_y), + GRID_EDGE, + 1.0, + ); + let bottom_y = y_of_value(0, height); + stroke_line( + &mut frame, + Point::new(0.0, bottom_y), + Point::new(width, bottom_y), + GRID_EDGE, + 1.0, + ); + + // Vertical grid every 64 pairs. + for pair in (0..ROW_LEN).step_by(64) { + let x = x_of_pair(pair, width); + stroke_line( + &mut frame, + Point::new(x, 0.0), + Point::new(x, height), + GRID_PAIR, + 1.0, + ); + } + + // Axis labels — tiny, unobtrusive. + for &level in &[0u8, 8, 16, 24, 31] { + frame.fill_text(Text { + content: level.to_string(), + position: Point::new(3.0, y_of_value(level, height) + 2.0), + color: LABEL, + size: 9.0.into(), + ..Default::default() + }); + } + for &pair in &[0usize, 128, 256, 384, 511] { + frame.fill_text(Text { + content: pair.to_string(), + position: Point::new((x_of_pair(pair, width) - 7.0).max(1.0), height - 12.0), + color: LABEL, + size: 9.0.into(), + ..Default::default() + }); + } + + if self.row.len() == ROW_LEN && width > 0.0 && height > 0.0 { + // Area fill under the curve. + let area = Path::new(|p| { + p.move_to(Point::new(0.0, bottom_y)); + for (pair, &value) in self.row.iter().enumerate() { + p.line_to(Point::new( + x_of_pair(pair, width), + y_of_value(value, height), + )); + } + p.line_to(Point::new(width, bottom_y)); + p.close(); + }); + frame.fill(&area, CURVE_FILL); + + // The curve itself. + let line = Path::new(|p| { + let mut first = true; + for (pair, &value) in self.row.iter().enumerate() { + let point = Point::new(x_of_pair(pair, width), y_of_value(value, height)); + if first { + p.move_to(point); + first = false; + } else { + p.line_to(point); + } + } + }); + frame.stroke(&line, Stroke::default().with_color(CURVE).with_width(1.5)); + } + + // Selected pair: hairline + filled dot with ring. + let sel_x = x_of_pair(self.selected_pair.min(ROW_LEN - 1), width); + stroke_line( + &mut frame, + Point::new(sel_x, 0.0), + Point::new(sel_x, height), + SELECTION_HAIRLINE, + 1.0, + ); + let sel_value = self + .row + .get(self.selected_pair.min(ROW_LEN - 1)) + .copied() + .unwrap_or(0); + let center = Point::new(sel_x, y_of_value(sel_value, height)); + frame.fill(&Path::circle(center, 4.0), SELECTION); + frame.stroke( + &Path::circle(center, 6.5), + Stroke::default().with_color(SELECTION).with_width(1.0), + ); + + // Hover crosshair — derived straight from the cursor, no messages. + if let Some(pos) = cursor.position_in(bounds) { + let hover_pair = pair_from_x(pos.x, width); + let hover_value = value_from_y(pos.y, height); + let hx = x_of_pair(hover_pair, width); + let hy = y_of_value(hover_value, height); + stroke_line( + &mut frame, + Point::new(hx, 0.0), + Point::new(hx, height), + Color { a: 0.35, ..HOVER }, + 1.0, + ); + stroke_line( + &mut frame, + Point::new(0.0, hy), + Point::new(width, hy), + Color { a: 0.20, ..HOVER }, + 1.0, + ); + frame.stroke( + &Path::circle(Point::new(hx, hy), 3.0), + Stroke::default().with_color(HOVER).with_width(1.0), + ); + } + + vec![frame.into_geometry()] + } + + fn mouse_interaction( + &self, + _state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::Idle + } + } +} + +fn stroke_line(frame: &mut Frame, from: Point, to: Point, color: Color, width: f32) { + let path = Path::line(from, to); + frame.stroke(&path, Stroke::default().with_color(color).with_width(width)); +} diff --git a/dispel-gui/src/editors/fog_data/message.rs b/dispel-gui/src/editors/fog_data/message.rs new file mode 100644 index 00000000..20e393f6 --- /dev/null +++ b/dispel-gui/src/editors/fog_data/message.rs @@ -0,0 +1,41 @@ +/// Messages for the `ExtraInGame/fogdata.dat` editor. +/// +/// Every variant carries the owning `tab_id` so handlers always mutate the +/// correct per-tab state, even when several fog tabs are open at once. +#[derive(Debug, Clone)] +pub enum FogDataMessage { + /// Select a light level (1-based, `1..=123`) in a tab. + LevelSelected(usize, u32), + /// Select a pixel-pair index (`0..512`) in a tab. + PairSelected(usize, usize), + /// Live paint while dragging across the curve canvas. + /// `(tab_id, pair, quantized factor value)` + FactorPainted(usize, usize, u8), + /// A paint stroke finished (mouse released) — commits the undo snapshot + /// taken at stroke start, if anything actually changed. + StrokeEnded(usize), + /// Commit a single factor edit from the inspector field / steppers. + /// Pushes an undo snapshot before applying. `(tab_id, pair, value)` + FactorCommitted(usize, usize, u8), + /// The numeric edit field changed — validated live, committed on submit. + ValueInputChanged(usize, String), + /// Enter pressed in the numeric field — commit if valid. + ValueSubmitted(usize), + /// Save the fade tables back to disk (async). + Save(usize), + /// Save finished. `(tab_id, save generation the run was based on, result)` + /// The completion only clears `dirty` when its generation still matches + /// the editor's current one (no edit landed while saving). `Ok` carries + /// `(status, optional mod-recording failure)` — the file was written. + SaveComplete(usize, u64, Result<(String, Option), String>), + /// Reload from disk; asks for confirmation when dirty. + Revert(usize), + /// User confirmed discarding unsaved edits. + RevertConfirmed(usize), + /// User cancelled the revert confirmation. + RevertCancelled(usize), + /// Undo the last paint stroke / committed edit. + Undo(usize), + /// Redo the last undone edit. + Redo(usize), +} diff --git a/dispel-gui/src/editors/fog_data/mod.rs b/dispel-gui/src/editors/fog_data/mod.rs new file mode 100644 index 00000000..b2f9749a --- /dev/null +++ b/dispel-gui/src/editors/fog_data/mod.rs @@ -0,0 +1,15 @@ +// fog_data editor module +// Editor for `ExtraInGame/fogdata.dat` — observed map-lighting fade tables +// (123 levels × 512 brightness factors, each byte a flicker-curve sample). + +mod curve_canvas; +mod message; +mod state; +mod update; +mod view; + +pub use curve_canvas::FogCurveCanvas; +pub use message::FogDataMessage; +pub use state::FogDataEditorState; +pub use update::{RecordingParams, handle, save_fog_data, save_to_disk}; +pub use view::view; diff --git a/dispel-gui/src/editors/fog_data/state.rs b/dispel-gui/src/editors/fog_data/state.rs new file mode 100644 index 00000000..92525815 --- /dev/null +++ b/dispel-gui/src/editors/fog_data/state.rs @@ -0,0 +1,325 @@ +use dispel_core::map::fogdata::{FogData, MAX_FACTOR, ROW_LEN, ROWS}; +use std::fs::File; +use std::path::{Path, PathBuf}; + +/// Maximum undo snapshots kept per tab. +const UNDO_CAP: usize = 50; + +/// Per-tab editor state for `ExtraInGame/fogdata.dat` — observed +/// map-lighting fade tables (123 levels × 512 brightness factors). +/// +/// Undo/redo is snapshot-based (whole `FogData` clones): a snapshot is taken +/// at paint-stroke start and pushed when the stroke ends, or pushed directly +/// before a single-value commit from the inspector. +#[derive(Debug, Clone)] +pub struct FogDataEditorState { + pub path: PathBuf, + pub save_path: PathBuf, + pub name: String, + /// Parsed fade tables; `None` while unloaded. + pub fog: Option, + /// Load failure — replaces the whole editor surface. Never used for + /// transient save errors (those go to the status bar). + pub error: Option, + /// Selected light level (1-based, `1..=[ROWS]`). + pub selected_level: u32, + /// Selected pixel-pair index (`0..[ROW_LEN]`). + pub selected_pair: usize, + pub dirty: bool, + pub undo_stack: Vec, + pub redo_stack: Vec, + /// Curve snapshot captured when a paint stroke begins; pushed onto the + /// undo stack when the stroke ends — but only if data actually changed. + pending_stroke_base: Option, + /// Raw contents of the inspector numeric field. + pub value_input: String, + /// Inline validation error for [`Self::value_input`], if invalid. + pub input_error: Option, + /// Revert confirmation dialog is open (shown only when dirty). + pub confirm_revert: bool, + /// Bumped on every mutating action and on each `Save` dispatch. A + /// `SaveComplete` carrying a stale generation must not clear `dirty` + /// (an edit may have landed while the save was in flight). + pub save_generation: u64, +} + +impl Default for FogDataEditorState { + fn default() -> Self { + Self { + path: PathBuf::new(), + save_path: PathBuf::new(), + name: String::new(), + fog: None, + error: None, + selected_level: 1, + selected_pair: 0, + dirty: false, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + pending_stroke_base: None, + value_input: String::new(), + input_error: None, + confirm_revert: false, + save_generation: 0, + } + } +} + +impl FogDataEditorState { + /// Opens and parses a `fogdata.dat` synchronously (the file is 63 KB). + /// On failure the state is still usable and surfaces [`Self::error`]. + pub fn load_from_path(path: &Path) -> Self { + let name = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + match File::open(path).and_then(|mut f| FogData::parse(&mut f)) { + Ok(fog) => { + let mut state = Self { + path: path.to_path_buf(), + save_path: path.to_path_buf(), + name, + fog: Some(fog), + ..Default::default() + }; + state.sync_input_from_selection(); + state + } + Err(e) => Self { + path: path.to_path_buf(), + save_path: path.to_path_buf(), + name, + error: Some(e.to_string()), + ..Default::default() + }, + } + } + + /// Number of light levels in the table (`ROWS`). + pub fn level_count(&self) -> usize { + ROWS + } + + /// The full 512-sample flicker curve of the selected level. + /// Empty slice when no data is loaded. + pub fn current_row(&self) -> &[u8] { + static EMPTY: [u8; 0] = []; + match &self.fog { + Some(fog) if self.selected_level >= 1 && (self.selected_level as usize) <= ROWS => { + fog.row(self.selected_level) + } + _ => &EMPTY, + } + } + + /// Factor of the currently selected pair, if data is loaded. + pub fn selected_factor(&self) -> Option { + self.fog + .as_ref() + .and_then(|f| f.get_factor(self.selected_level, self.selected_pair)) + } + + // ── Editing ─────────────────────────────────────────────────────────── + + /// Single funnel into `FogData::set_factor`. + /// + /// Kept isolated so the core's error type can change under us + /// (`io::Error` today, typed `SetFactorError` later) without touching + /// any call site — everything here speaks plain strings. + fn apply_factor(&mut self, pair: usize, value: u8) -> Result<(), String> { + let Some(ref mut fog) = self.fog else { + return Err("No fog data loaded".to_string()); + }; + // Guard rails before hitting the core so an out-of-range value can + // never panic or wrap: values >31 wrap silently when consumed. + if value > MAX_FACTOR { + return Err(format!("Value must be 0–{MAX_FACTOR}")); + } + if pair >= ROW_LEN || self.selected_level == 0 || self.selected_level as usize > ROWS { + return Err("Pair index out of range".to_string()); + } + fog.set_factor(self.selected_level, pair, value) + .map_err(|e| e.to_string()) + } + + /// Capture the pre-stroke curve snapshot (idempotent within a stroke). + pub fn begin_stroke_if_needed(&mut self) { + if self.pending_stroke_base.is_none() { + self.pending_stroke_base = self.fog.clone(); + } + } + + /// Close the current paint stroke: pushes the stroke-start snapshot onto + /// the undo stack only when the curve actually changed during the stroke. + pub fn end_stroke(&mut self) { + if let Some(base) = self.pending_stroke_base.take() + && let Some(ref current) = self.fog + && base != *current + { + self.undo_stack.push(base); + self.redo_stack.clear(); + if self.undo_stack.len() > UNDO_CAP { + self.undo_stack.remove(0); + } + } + } + + /// Paint one sample during a drag. Returns `true` when the value changed. + pub fn paint_factor(&mut self, pair: usize, value: u8) -> bool { + let before = self + .fog + .as_ref() + .and_then(|f| f.get_factor(self.selected_level, pair)); + if self.apply_factor(pair, value).is_err() { + return false; + } + before != Some(value) + } + + /// Commit a single factor edit with its own undo snapshot. + /// Returns whether the curve actually changed; state is untouched on + /// rejection. + pub fn commit_factor(&mut self, pair: usize, value: u8) -> Result { + let current = self + .fog + .as_ref() + .and_then(|f| f.get_factor(self.selected_level, pair)); + if current == Some(value) { + return Ok(false); // No-op edit: no dirty flag, no undo entry. + } + let snapshot = self.fog.clone(); + self.apply_factor(pair, value)?; + if let Some(snapshot) = snapshot { + self.undo_stack.push(snapshot); + self.redo_stack.clear(); + if self.undo_stack.len() > UNDO_CAP { + self.undo_stack.remove(0); + } + } + Ok(true) + } + + // ── Inspector field ─────────────────────────────────────────────────── + + /// Refresh the numeric buffer from the current selection. + pub fn sync_input_from_selection(&mut self) { + if let Some(v) = self.selected_factor() { + self.value_input = v.to_string(); + self.input_error = None; + } + } + + /// Live-validate the numeric field: `0..=MAX_FACTOR`, integers only. + pub fn set_value_input(&mut self, raw: String) { + self.input_error = validate_value_text(&raw); + self.value_input = raw; + } + + /// Commit whatever is in the numeric field to the selected pair. + /// Returns `Some(value)` on success; on failure the reason is already + /// stored in [`Self::input_error`]. + pub fn submit_value_input(&mut self) -> Option { + if let Some(msg) = validate_value_text(&self.value_input) { + self.input_error = Some(msg); + return None; + } + let value: u8 = self.value_input.parse().unwrap_or(u8::MAX); + match self.commit_factor(self.selected_pair, value) { + Ok(_changed) => { + self.input_error = None; + Some(value) + } + Err(e) => { + self.input_error = Some(e); + None + } + } + } + + // ── Undo / redo ─────────────────────────────────────────────────────── + + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Restores the previous curve. Returns `true` when something was undone. + pub fn undo(&mut self) -> bool { + let Some(previous) = self.undo_stack.pop() else { + return false; + }; + if let Some(current) = self.fog.take() { + self.redo_stack.push(current); + } + self.fog = Some(previous); + self.dirty = true; + self.sync_input_from_selection(); + true + } + + /// Re-applies the last undone edit. Returns `true` when it did anything. + pub fn redo(&mut self) -> bool { + let Some(next) = self.redo_stack.pop() else { + return false; + }; + if let Some(current) = self.fog.take() { + self.undo_stack.push(current); + } + self.fog = Some(next); + self.dirty = true; + self.sync_input_from_selection(); + true + } + + // ── Dirty / revert ──────────────────────────────────────────────────── + + pub fn mark_clean(&mut self) { + self.dirty = false; + } + + /// Reload the table from disk, discarding all edits and history. + pub fn reload_from_disk(&mut self) -> Result<(), String> { + let file = File::open(&self.save_path).map_err(|e| e.to_string())?; + let mut file = file; + let fog = FogData::parse(&mut file).map_err(|e| e.to_string())?; + self.fog = Some(fog); + self.dirty = false; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.pending_stroke_base = None; + self.confirm_revert = false; + self.sync_input_from_selection(); + Ok(()) + } +} + +/// Validate raw text as a brightness factor: unsigned integer `0..=31`. +fn validate_value_text(raw: &str) -> Option { + let trimmed = raw.trim(); + match trimmed.parse::() { + Ok(v) if v <= MAX_FACTOR => None, + Ok(_) => Some(format!("Must be 0–{MAX_FACTOR}")), + Err(_) if trimmed.is_empty() => Some("Enter a number".to_string()), + Err(_) => Some("Not a number".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_value_text_accepts_range_and_rejects_rest() { + assert_eq!(validate_value_text("0"), None); + assert_eq!(validate_value_text("31"), None); + assert_eq!(validate_value_text(" 7 "), None); + assert!(validate_value_text("32").is_some()); + assert!(validate_value_text("-1").is_some()); + assert!(validate_value_text("abc").is_some()); + assert!(validate_value_text("").is_some()); + } +} diff --git a/dispel-gui/src/editors/fog_data/update.rs b/dispel-gui/src/editors/fog_data/update.rs new file mode 100644 index 00000000..202b4586 --- /dev/null +++ b/dispel-gui/src/editors/fog_data/update.rs @@ -0,0 +1,297 @@ +use crate::app::App; +use crate::editors::fog_data::message::FogDataMessage; +use crate::message::{Message, MessageExt}; +use dispel_core::map::fogdata::{MAX_FACTOR, ROW_LEN, ROWS}; +use iced::Task; +use std::path::PathBuf; + +/// Parameters for recording a fogdata save into an active mod session. +#[derive(Debug, Clone)] +pub struct RecordingParams { + pub workspace_root: PathBuf, + pub game_path: PathBuf, + pub mod_slug: String, + pub relative_path: String, +} + +/// Mark the workspace tab's modified flag. +/// +/// Inlined at call sites (rather than taking `&mut App`) so the borrow +/// checker can split it from the outstanding `fog_editors` borrow. +macro_rules! mark_tab_modified { + ($app:expr, $tab_id:expr, $modified:expr) => { + if let Some(tab) = $app + .state + .workspace + .tabs + .iter_mut() + .find(|t| t.id == $tab_id) + { + tab.modified = $modified; + } + }; +} + +pub fn handle(message: FogDataMessage, app: &mut App) -> Task { + // Every variant names its tab, so we always mutate the right editor. + let tab_id = message.tab_id(); + let Some(editor) = app.state.editors.fog_editors.get_mut(&tab_id) else { + return Task::none(); + }; + + match message { + // ── Navigation ─────────────────────────────────────────────────────── + FogDataMessage::LevelSelected(_, level) => { + if level >= 1 && (level as usize) <= ROWS { + editor.selected_level = level; + editor.sync_input_from_selection(); + return level_list_scroll_task(level); + } + } + FogDataMessage::PairSelected(_, pair) => { + if pair < ROW_LEN { + editor.selected_pair = pair; + editor.sync_input_from_selection(); + } + } + + // ── Curve painting ─────────────────────────────────────────────────── + FogDataMessage::FactorPainted(_, pair, value) => { + if value > MAX_FACTOR || pair >= ROW_LEN { + return Task::none(); // Never trust the pointer beyond bounds. + } + editor.begin_stroke_if_needed(); + editor.selected_pair = pair; + let changed = editor.paint_factor(pair, value); + if changed { + editor.dirty = true; + editor.save_generation += 1; + mark_tab_modified!(app, tab_id, true); + } + editor.sync_input_from_selection(); + } + FogDataMessage::StrokeEnded(_) => { + editor.end_stroke(); + } + + // ── Inspector edits ────────────────────────────────────────────────── + FogDataMessage::FactorCommitted(_, pair, value) => { + if value > MAX_FACTOR || pair >= ROW_LEN { + return Task::none(); + } + match editor.commit_factor(pair, value) { + Ok(changed) => { + editor.input_error = None; + editor.sync_input_from_selection(); + if changed { + editor.dirty = true; + editor.save_generation += 1; + mark_tab_modified!(app, tab_id, true); + } + } + Err(e) => editor.input_error = Some(e), + } + } + FogDataMessage::ValueInputChanged(_, raw) => { + editor.set_value_input(raw); + } + FogDataMessage::ValueSubmitted(_) => { + if editor.submit_value_input().is_some() { + editor.dirty = true; + editor.save_generation += 1; + mark_tab_modified!(app, tab_id, true); + } + } + + // ── Save ───────────────────────────────────────────────────────────── + FogDataMessage::Save(_) => { + // Capture the generation this save is based on; any edit (or + // another save) bumps it and invalidates the completion. + editor.save_generation += 1; + let save_generation = editor.save_generation; + let path = editor.save_path.clone(); + let fog = editor.fog.clone(); + let recording = mod_recording_params(app, &path); + app.state.status_msg = "Saving fogdata.dat…".to_string(); + return Task::perform( + async move { save_fog_data(path, fog, recording).await }, + move |result| { + Message::fog_data(FogDataMessage::SaveComplete( + tab_id, + save_generation, + result, + )) + }, + ); + } + FogDataMessage::SaveComplete(_, generation, result) => match result { + Ok((msg, recording_error)) => { + // Only mark clean when no edit landed while the save was in + // flight — a stale completion must keep the tab dirty. + if generation == editor.save_generation { + editor.mark_clean(); + mark_tab_modified!(app, tab_id, false); + } + // The file on disk IS current even when only the mod + // recording failed — report both facts separately. + app.state.status_msg = match recording_error { + Some(err) => format!("{msg} — Mod recording failed: {err}"), + None => msg, + }; + } + Err(e) => { + // Transient failure: keep the editor dirty and report via the + // status bar; the load-error surface stays reserved for parse + // failures. + app.state.status_msg = format!("Save failed: {e}"); + } + }, + + // ── Revert ─────────────────────────────────────────────────────────── + FogDataMessage::Revert(_) => { + if !editor.dirty { + // Nothing to lose — reload straight away. + match editor.reload_from_disk() { + Ok(()) => app.state.status_msg = "Reverted fogdata.dat".to_string(), + Err(e) => app.state.status_msg = format!("Revert failed: {e}"), + } + } else { + editor.confirm_revert = true; + } + } + FogDataMessage::RevertConfirmed(_) => match editor.reload_from_disk() { + Ok(()) => { + mark_tab_modified!(app, tab_id, false); + app.state.status_msg = "Reverted fogdata.dat".to_string(); + } + Err(e) => { + editor.confirm_revert = false; + app.state.status_msg = format!("Revert failed: {e}"); + } + }, + FogDataMessage::RevertCancelled(_) => { + editor.confirm_revert = false; + } + + // ── Undo / redo (also reachable via the global Ctrl+Z/Ctrl+Y path) ─── + FogDataMessage::Undo(_) => { + if editor.undo() { + editor.save_generation += 1; + mark_tab_modified!(app, tab_id, editor.dirty); + } else { + app.state.status_msg = "Nothing to undo".to_string(); + } + } + FogDataMessage::Redo(_) => { + if editor.redo() { + editor.save_generation += 1; + mark_tab_modified!(app, tab_id, true); + } else { + app.state.status_msg = "Nothing to redo".to_string(); + } + } + } + + Task::none() +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn mod_recording_params(app: &App, save_path: &std::path::Path) -> Option { + let session = app.state.recording.as_ref()?; + let game_path = PathBuf::from(&app.state.shared_game_path); + let relative_path = relative_to(save_path, &game_path).unwrap_or_default(); + Some(RecordingParams { + workspace_root: session.workspace_root.clone(), + game_path, + mod_slug: session.mod_slug.clone(), + relative_path, + }) +} + +fn relative_to(path: &std::path::Path, base: &std::path::Path) -> Option { + path.strip_prefix(base) + .ok() + .map(|r| r.to_string_lossy().replace('\\', "/")) +} + +/// Keep the selected level visible in the scrollable list. +fn level_list_scroll_task(level: u32) -> Task { + use iced::widget::scrollable::RelativeOffset; + let fraction = ((level.saturating_sub(1)) as f32 / ROWS as f32).clamp(0.0, 1.0); + iced::widget::operation::snap_to::( + iced::widget::Id::new(crate::editors::fog_data::view::LEVEL_LIST_ID), + RelativeOffset { + x: None, + y: Some(fraction), + }, + ) +} + +// ── Save logic ──────────────────────────────────────────────────────────────── + +/// Save outcome: `(status message, optional mod-recording failure)`. +/// `Ok` always means the file was written to disk; a recording failure is +/// reported separately so it can't mask the successful save. +type SaveOutcome = (String, Option); + +pub async fn save_fog_data( + path: PathBuf, + fog: Option, + recording: Option, +) -> Result { + let fog = fog.ok_or_else(|| "No fog data loaded".to_string())?; + tokio::task::spawn_blocking(move || { + save_to_disk(&path, &fog)?; + + let mut recording_error = None; + if let Some(params) = recording { + let current_bytes = + std::fs::read(&path).map_err(|e| format!("Failed to read saved file: {e}"))?; + if let Err(e) = crate::editors::mod_packager::recording::record_file_replace( + ¶ms.workspace_root, + ¶ms.game_path, + ¶ms.mod_slug, + ¶ms.relative_path, + ¤t_bytes, + ) { + recording_error = Some(e); + } + } + + Ok((format!("Saved → {}", path.display()), recording_error)) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Synchronous disk write — the unit of work behind [`save_fog_data`], +/// exposed for tests. +pub fn save_to_disk( + path: &std::path::Path, + fog: &dispel_core::map::fogdata::FogData, +) -> Result<(), String> { + fog.save_file(path).map_err(|e| e.to_string()) +} + +impl FogDataMessage { + /// The owning tab id carried by every variant. + pub fn tab_id(&self) -> usize { + match self { + Self::LevelSelected(id, _) + | Self::PairSelected(id, _) + | Self::FactorPainted(id, _, _) + | Self::StrokeEnded(id) + | Self::FactorCommitted(id, _, _) + | Self::ValueInputChanged(id, _) + | Self::ValueSubmitted(id) + | Self::Save(id) + | Self::SaveComplete(id, _, _) + | Self::Revert(id) + | Self::RevertConfirmed(id) + | Self::RevertCancelled(id) + | Self::Undo(id) + | Self::Redo(id) => *id, + } + } +} diff --git a/dispel-gui/src/editors/fog_data/view.rs b/dispel-gui/src/editors/fog_data/view.rs new file mode 100644 index 00000000..13e1b23b --- /dev/null +++ b/dispel-gui/src/editors/fog_data/view.rs @@ -0,0 +1,338 @@ +use crate::app::App; +use crate::components::utils::horizontal_rule; +use crate::editors::fog_data::message::FogDataMessage; +use crate::editors::fog_data::state::FogDataEditorState; +use crate::message::{Message, MessageExt}; +use crate::style; +use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; +use iced::widget::{button, column, container, row, scrollable, space::Space, text, text_input}; +use iced::{Alignment, Color, Element, Fill, Length}; +use lucide_icons::Icon; + +/// Id of the scrollable level list (used to keep the selection on screen). +pub const LEVEL_LIST_ID: &str = "fog-data-level-list"; +const LEVEL_LIST_WIDTH: f32 = 76.0; + +/// Shorthand for wrapping a message into the top-level `Message`. +fn fog(m: FogDataMessage) -> Message { + Message::fog_data(m) +} + +/// Warm danger tone for inline validation errors. +fn error_text(theme: &iced::Theme) -> text::Style { + let _ = theme; + text::Style { + color: Some(Color::from_rgb(0.90, 0.45, 0.38)), + } +} + +pub fn view(app: &App) -> Element<'_, Message> { + let tab_id = app + .state + .workspace + .active() + .map(|t| t.id) + .unwrap_or(usize::MAX); + + let Some(editor) = app.state.editors.fog_editors.get(&tab_id) else { + return container(text("Fog data not loaded").size(14)) + .width(Fill) + .height(Fill) + .padding(16) + .accessible_label("Fog data editor") + .into(); + }; + + // Parse failure → dedicated error surface; never panic. + if let Some(ref err) = editor.error { + return container( + column![ + text("Failed to load fogdata.dat") + .size(15) + .style(style::primary_text), + text(err).size(12).style(style::subtle_text), + text("The file is missing or not exactly 62,976 bytes.") + .size(12) + .style(style::subtle_text), + ] + .spacing(8), + ) + .width(Fill) + .height(Fill) + .padding(16) + .accessible_label("Fog data editor") + .into(); + } + + let base = column![ + view_header(tab_id, editor), + horizontal_rule(1), + row![ + view_level_list(tab_id, editor), + view_curve_and_inspector(tab_id, editor) + ] + .width(Fill) + .height(Fill), + ] + .width(Fill) + .height(Fill); + + // Revert confirmation overlays the editor when dirty. + if editor.confirm_revert { + use gui_widgets::components::modal::modal; + let base_element: Element<'_, Message> = base.into(); + let tab = tab_id; + modal( + base_element, + view_revert_confirm(tab), + move || fog(FogDataMessage::RevertCancelled(tab)), + 0.5, + ) + } else { + base.into() + } +} + +// ── Header / toolbar ────────────────────────────────────────────────────────── + +fn view_header(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> { + let title: Element<'_, Message> = if editor.dirty { + row![ + text("fogdata.dat").size(13).style(style::primary_text), + text(" *").size(13).style(error_text), + ] + .spacing(0) + .align_y(Alignment::Center) + .into() + } else { + text("fogdata.dat") + .size(13) + .style(style::primary_text) + .into() + }; + + // Level stepping. + let level = editor.selected_level; + let prev = button( + text(icon_char(Icon::ChevronLeft)) + .font(LUCIDE_FONT) + .size(12), + ) + .on_press_maybe((level > 1).then(|| fog(FogDataMessage::LevelSelected(tab_id, level - 1)))) + .padding([3, 7]) + .style(style::playback_button); + + let level_label = text(format!("Level {level} / {}", editor.level_count())) + .size(12) + .style(style::section_header); + + let next = button( + text(icon_char(Icon::ChevronRight)) + .font(LUCIDE_FONT) + .size(12), + ) + .on_press_maybe( + ((level as usize) < editor.level_count()) + .then(|| fog(FogDataMessage::LevelSelected(tab_id, level + 1))), + ) + .padding([3, 7]) + .style(style::playback_button); + + // History controls. + let undo_btn = button(text(icon_char(Icon::Undo2)).font(LUCIDE_FONT).size(12)) + .on_press_maybe(editor.can_undo().then(|| fog(FogDataMessage::Undo(tab_id)))) + .padding([3, 7]) + .style(style::playback_button); + + let redo_btn = button(text(icon_char(Icon::Redo2)).font(LUCIDE_FONT).size(12)) + .on_press_maybe(editor.can_redo().then(|| fog(FogDataMessage::Redo(tab_id)))) + .padding([3, 7]) + .style(style::playback_button); + + // File actions. + let revert_btn = button(text("Revert").size(12)) + .on_press(fog(FogDataMessage::Revert(tab_id))) + .padding([4, 10]) + .style(style::chip); + + let save_label = if editor.dirty { "Save *" } else { "Save" }; + let save_btn = button(text(save_label).size(12)) + .on_press(fog(FogDataMessage::Save(tab_id))) + .padding([4, 10]) + .style(style::export_button); + + container( + row![ + title, + Space::new().width(Fill), + prev, + level_label, + next, + Space::new().width(8), + undo_btn, + redo_btn, + Space::new().width(4), + revert_btn, + save_btn, + ] + .spacing(6) + .align_y(Alignment::Center), + ) + .padding([6, 12]) + .width(Fill) + .into() +} + +// ── Level list ──────────────────────────────────────────────────────────────── + +fn view_level_list(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> { + let selected = editor.selected_level; + + let rows: Vec> = (1..=editor.level_count() as u32) + .map(|level| { + let label: Element<'_, Message> = text(level.to_string()) + .size(11) + .style(if selected == level { + style::section_header + } else { + style::subtle_text + }) + .into(); + button(container(label).width(Fill).align_x(Alignment::End)) + .on_press(fog(FogDataMessage::LevelSelected(tab_id, level))) + .padding([3, 10]) + .width(Fill) + .style(if selected == level { + style::selected_row_button + } else { + style::normal_row_button + }) + .into() + }) + .collect(); + + container( + scrollable(column(rows).spacing(1).padding([4, 4])) + .id(iced::widget::Id::new(LEVEL_LIST_ID)) + .width(Fill) + .height(Fill), + ) + .width(Length::Fixed(LEVEL_LIST_WIDTH)) + .height(Fill) + .into() +} + +// ── Curve + inspector strip ─────────────────────────────────────────────────── + +fn view_curve_and_inspector(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> { + let canvas = super::curve_canvas::FogCurveCanvas { + tab_id, + row: editor.current_row(), + selected_pair: editor.selected_pair, + }; + + column![ + container(canvas.into_element()) + .width(Fill) + .height(Fill) + .padding(8), + horizontal_rule(1), + view_inspector(tab_id, editor), + ] + .width(Fill) + .height(Fill) + .spacing(0) + .into() +} + +fn view_inspector(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> { + let pair = editor.selected_pair; + let value = editor.selected_factor().unwrap_or(0); + let max = dispel_core::map::fogdata::MAX_FACTOR; + + let readout = format!( + "Level {} · Pair {} · brightness {}/{} · {:.0}% light", + editor.selected_level, + pair, + value, + max, + value as f32 / 32.0 * 100.0, + ); + + let value_field = text_input("", &editor.value_input) + .width(Length::Fixed(56.0)) + .on_input(move |s| fog(FogDataMessage::ValueInputChanged(tab_id, s))) + .on_submit(fog(FogDataMessage::ValueSubmitted(tab_id))) + .size(12); + + // Steppers — clamped at both ends so they can never produce an invalid + // value. + let minus = button(text(icon_char(Icon::Minus)).font(LUCIDE_FONT).size(11)) + .on_press_maybe( + (value > 0).then(|| fog(FogDataMessage::FactorCommitted(tab_id, pair, value - 1))), + ) + .padding([2, 6]) + .style(style::chip); + + let plus = button(text(icon_char(Icon::Plus)).font(LUCIDE_FONT).size(11)) + .on_press_maybe( + (value < max).then(|| fog(FogDataMessage::FactorCommitted(tab_id, pair, value + 1))), + ) + .padding([2, 6]) + .style(style::chip); + + let error_line: Element<'_, Message> = match &editor.input_error { + Some(msg) => text(format!("⚠ {msg}")).size(11).style(error_text).into(), + None => Space::new().into(), + }; + + container( + row![ + text(readout).size(11).style(style::subtle_text), + Space::new().width(12), + minus, + value_field, + plus, + Space::new().width(8), + error_line, + Space::new().width(Fill), + text("drag across the curve to paint · click to select a pair") + .size(10) + .style(style::shortcut_hint), + ] + .spacing(5) + .padding([6, 12]) + .align_y(Alignment::Center), + ) + .width(Fill) + .into() +} + +// ── Revert confirmation ─────────────────────────────────────────────────────── + +fn view_revert_confirm(tab_id: usize) -> Element<'static, Message> { + let body = column![ + text("Discard changes?").size(14).style(style::primary_text), + text("The fade table has unsaved edits. Reloading from disk will discard every change in this tab.") + .size(12) + .style(style::subtle_text), + row![ + Space::new().width(Fill), + button(text("Cancel").size(12)) + .on_press(fog(FogDataMessage::RevertCancelled(tab_id))) + .padding([5, 12]) + .style(style::playback_button), + button(text("Discard & Reload").size(12)) + .on_press(fog(FogDataMessage::RevertConfirmed(tab_id))) + .padding([5, 12]) + .style(style::export_button), + ] + .spacing(8) + .align_y(Alignment::Center), + ] + .spacing(10) + .padding(20) + .width(Length::Fixed(360.0)); + + container(body).style(style::export_dialog_container).into() +} diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index 29fd181b..d9b61c09 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -51,6 +51,7 @@ fn make_state( }; let map_data = MapData { + overlay_modes: Vec::new(), model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 6ea14df1..313813fd 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -249,6 +249,7 @@ pub enum MapLayer { Ground, Buildings, Roofs, + Shadows, InternalSprites, Collisions, Events, diff --git a/dispel-gui/src/editors/map_editor/state.rs b/dispel-gui/src/editors/map_editor/state.rs index da1d4d39..1382b2a4 100644 --- a/dispel-gui/src/editors/map_editor/state.rs +++ b/dispel-gui/src/editors/map_editor/state.rs @@ -165,6 +165,9 @@ pub struct MapDataState { pub redo_stack: VecDeque, /// True when there are unsaved entity changes. pub dirty: bool, + /// Lighting fade tables for the observed shadow pass. Loaded only + /// when this map is flagged Dark in AllMap.ini and a game path is set. + pub shadow_fog: Option>, /// True while an async entity save is in flight. pub is_saving: bool, /// True while an async PNG export is in flight. @@ -204,6 +207,7 @@ impl Default for MapDataState { undo_stack: VecDeque::new(), redo_stack: VecDeque::new(), dirty: false, + shadow_fog: None, is_saving: false, is_exporting: false, toasts: Vec::new(), @@ -413,6 +417,10 @@ impl MapRenderSource for MapEditorState { self.data.tiles_ready } + fn shadow_data(&self) -> Option<&std::sync::Arc> { + self.data.shadow_fog.as_ref() + } + fn view(&self) -> &MapViewState { &self.view } diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 56e5851f..91b02354 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -504,6 +504,7 @@ mod tests { let mut object_ids = HashMap::new(); object_ids.insert((10, 10), 42); let map_data = MapData { + overlay_modes: Vec::new(), model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index 3dd4da3f..2165710b 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -25,6 +25,7 @@ pub fn layer_visible(view: &MapViewState, layer: MapLayer) -> bool { MapLayer::Ground => view.show_ground, MapLayer::Buildings => view.show_buildings, MapLayer::Roofs => view.show_roofs, + MapLayer::Shadows => view.show_shadows, MapLayer::InternalSprites => view.show_internal_sprites, MapLayer::Collisions => view.show_collisions, MapLayer::Events => view.show_events, @@ -42,6 +43,7 @@ fn set_layer_visible(view: &mut MapViewState, layer: MapLayer, visible: bool) { MapLayer::Ground => view.show_ground = visible, MapLayer::Buildings => view.show_buildings = visible, MapLayer::Roofs => view.show_roofs = visible, + MapLayer::Shadows => view.show_shadows = visible, MapLayer::InternalSprites => view.show_internal_sprites = visible, MapLayer::Collisions => view.show_collisions = visible, MapLayer::Events => view.show_events = visible, @@ -293,6 +295,22 @@ pub fn map_loaded( }) .collect(); + // Load the shadow/lighting fade tables for Dark maps (no-op for + // Light maps or when no game path is configured). + let game_path = app.state.workspace.game_path.clone(); + let map_stem = state + .data + .map_path + .as_ref() + .and_then(|p| p.file_stem()) + .map(|s| s.to_string_lossy().to_string()); + state.data.shadow_fog = match (game_path, map_stem) { + (Some(gp), Some(stem)) => { + dispel_core::map::render::load_fog_if_dark(&gp, &stem).map(std::sync::Arc::new) + } + _ => None, + }; + state.data.loading_state = LoadingState::Loaded(handle); state.view.tile_layer_cache.clear(); state.view.overlay_cache.clear(); @@ -980,6 +998,7 @@ mod tests { occluded_map_in_pixels_height: 8 * 32, }; let map_data = MapData { + overlay_modes: Vec::new(), model, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 1f5744e6..2ba8d6a0 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -197,6 +197,7 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { MapLayer::Objects => state.view.show_objects = !state.view.show_objects, MapLayer::DrawItems => state.view.show_draw_items = !state.view.show_draw_items, MapLayer::ObjectIds => state.view.show_object_ids = !state.view.show_object_ids, + MapLayer::Shadows => state.view.show_shadows = !state.view.show_shadows, } // Hiding the layer that owns the active editing tool makes that // tool meaningless — reset to Pan. diff --git a/dispel-gui/src/editors/map_editor/update/persistence.rs b/dispel-gui/src/editors/map_editor/update/persistence.rs index 31ed1a70..5c7cb3b9 100644 --- a/dispel-gui/src/editors/map_editor/update/persistence.rs +++ b/dispel-gui/src/editors/map_editor/update/persistence.rs @@ -360,7 +360,6 @@ pub fn export_image(app: &mut App, tab_id: usize) -> Task { reader: &mut reader, output_path: &output_path, data: &map_data, - occlusion: false, gtl_tileset: >l_tiles, btl_tileset: &btl_tiles, map_id: &map_id, diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index a88ef297..90fbfc68 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -111,6 +111,13 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::Roofs, None ), + layer_toggle( + "Shadows", + state.view.show_shadows, + tab_id, + MapLayer::Shadows, + None + ), layer_toggle( "Sprites", state.view.show_internal_sprites, @@ -556,7 +563,6 @@ pub fn view(app: &App) -> Element<'_, Message> { move || Message::map_editor(MapEditorMessage::HideDialogPreview(tab_id)), 0.5, ) - .into() } else { base }; diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs index 987ed437..9d550ff6 100644 --- a/dispel-gui/src/editors/mod.rs +++ b/dispel-gui/src/editors/mod.rs @@ -20,6 +20,7 @@ pub mod event_npc_ref; pub mod event_scr; pub mod extra_ini; pub mod extra_ref; +pub mod fog_data; pub mod heal_item; pub mod hex_wrapper; pub mod localization_manager; diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs index 99df92ae..94e76a54 100644 --- a/dispel-gui/src/message/editor/mod.rs +++ b/dispel-gui/src/message/editor/mod.rs @@ -15,6 +15,7 @@ use crate::editors::event_npc_ref::EventNpcRefEditorMessage; use crate::editors::event_scr::EventScrEditorMessage; use crate::editors::extra_ini::ExtraIniEditorMessage; use crate::editors::extra_ref::ExtraRefEditorMessage; +use crate::editors::fog_data::FogDataMessage; use crate::editors::heal_item::HealItemEditorMessage; use crate::editors::localization_manager::LocalizationMessage; use crate::editors::magic::MagicEditorMessage; @@ -82,5 +83,6 @@ pub enum EditorMessage { Localization(LocalizationMessage), SaveFileViewer(SaveFileViewerMessage), SaveIfo(SaveIfoEditorMessage), + FogData(FogDataMessage), HexEditor(HexEditorMessage), } diff --git a/dispel-gui/src/message/ext.rs b/dispel-gui/src/message/ext.rs index ab506c8b..a9a25830 100644 --- a/dispel-gui/src/message/ext.rs +++ b/dispel-gui/src/message/ext.rs @@ -40,6 +40,7 @@ define_message_ext! { hex_editor: HexEditor(hexedit::HexEditorMessage), save_file_viewer: SaveFileViewer(crate::editors::save_file_viewer::SaveFileViewerMessage), save_ifo: SaveIfo(crate::editors::save_ifo::SaveIfoEditorMessage), + fog_data: FogData(crate::editors::fog_data::FogDataMessage), } /// Extension trait for building nested editor messages. diff --git a/dispel-gui/src/recording_tests.rs b/dispel-gui/src/recording_tests.rs index a802a23d..b6853e53 100644 --- a/dispel-gui/src/recording_tests.rs +++ b/dispel-gui/src/recording_tests.rs @@ -1128,3 +1128,135 @@ fn party_level_db_no_recording_when_no_npc_selected() { "party_level_db should NOT record when no NPC selected" ); } + +// ============================================================================ +// fog_data — whole-file recorder (save-time `record_file_replace`) +// ============================================================================ + +/// Fixture fade-table path, or `None` when fixtures are absent. +fn fixture_fogdata_path() -> Option { + let p = PathBuf::from("../fixtures/Dispel/ExtraInGame/fogdata.dat"); + p.exists().then_some(p) +} + +#[test] +fn fog_data_editor_records_file_replace_on_save_when_session_active() { + use crate::editors::fog_data::{self, FogDataEditorState}; + use dispel_core::map::fogdata::MAX_FACTOR; + use dispel_core::modding::{ModManifest, Workspace as ModWorkspace}; + + let Some(fixture) = fixture_fogdata_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + + // Temp mod workspace + temp "game dir" holding a copy of the fixture. + let base = std::env::temp_dir().join(format!( + "dispel-fog-rec-{}-{}", + std::process::id(), + fixture.file_name().map(|n| n.len()).unwrap_or(0) + )); + let ws_root = base.join("ws"); + let game_dir = base.join("game"); + std::fs::create_dir_all(game_dir.join("ExtraInGame")).expect("game dir created"); + let fog_path = game_dir.join("ExtraInGame/fogdata.dat"); + std::fs::copy(&fixture, &fog_path).expect("fixture copied"); + + let ws = ModWorkspace::open(ws_root.clone()).expect("workspace opened"); + let slug = ws + .create_mod(ModManifest::new("fog recording test")) + .expect("mod created"); + + // Load the copy and dirty it with one edit. + let mut state = FogDataEditorState::load_from_path(&fog_path); + assert!(state.error.is_none(), "fixture must parse cleanly"); + assert!(state.paint_factor(0, MAX_FACTOR), "edit applied"); + + // Save through the real async path with recording params wired up, + // mirroring what `FogDataMessage::Save` dispatches. + let params = fog_data::RecordingParams { + workspace_root: ws_root.clone(), + game_path: game_dir.clone(), + mod_slug: slug.clone(), + relative_path: "ExtraInGame/fogdata.dat".into(), + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + let outcome = rt.block_on(fog_data::save_fog_data( + fog_path.clone(), + state.fog.clone(), + Some(params), + )); + + let (status, recording_error) = outcome.expect("save succeeded"); + assert!( + status.contains("Saved"), + "status reports the save: {status}" + ); + assert!( + recording_error.is_none(), + "recording must succeed: {recording_error:?}" + ); + + // The changelog must now contain a recorded change for fogdata.dat. + let pkg = ws.read_mod(&slug).expect("mod readable"); + assert!( + !pkg.changes.is_empty(), + "save recorded a change into the mod changelog" + ); + assert!( + pkg.changes + .actions() + .iter() + .any(|a| a.file_path == "ExtraInGame/fogdata.dat"), + "recorded action targets fogdata.dat" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn fog_data_editor_save_without_session_writes_file_only() { + use crate::editors::fog_data::{self, FogDataEditorState}; + use dispel_core::map::fogdata::MAX_FACTOR; + + let Some(fixture) = fixture_fogdata_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + + let base = std::env::temp_dir().join(format!("dispel-fog-norec-{}", std::process::id())); + let game_dir = base.join("game"); + std::fs::create_dir_all(game_dir.join("ExtraInGame")).expect("game dir created"); + let fog_path = game_dir.join("ExtraInGame/fogdata.dat"); + std::fs::copy(&fixture, &fog_path).expect("fixture copied"); + + let mut state = FogDataEditorState::load_from_path(&fog_path); + assert!(state.paint_factor(1, MAX_FACTOR), "edit applied"); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let outcome = rt.block_on(fog_data::save_fog_data( + fog_path.clone(), + state.fog.clone(), + None, // no recording session active + )); + let (status, recording_error) = outcome.expect("save succeeded"); + assert!(status.contains("Saved")); + assert!(recording_error.is_none()); + // No session → no recording side effects; only the file write happened. + let mut round_trip = std::env::temp_dir().join("dispel-fog-norec-check.dat"); + state + .fog + .as_ref() + .unwrap() + .save_file(&round_trip) + .expect("re-encode ok"); + assert_eq!( + std::fs::read(&fog_path).unwrap(), + std::fs::read(&round_trip).unwrap(), + "edited table persisted to disk" + ); + let _ = std::fs::remove_file(&round_trip); + round_trip.pop(); + + let _ = std::fs::remove_dir_all(&base); +} diff --git a/dispel-gui/src/tests/capability_crosscheck.rs b/dispel-gui/src/tests/capability_crosscheck.rs index 49dde0e0..42063916 100644 --- a/dispel-gui/src/tests/capability_crosscheck.rs +++ b/dispel-gui/src/tests/capability_crosscheck.rs @@ -46,6 +46,7 @@ mod crosscheck { EditorType::ModPackager, EditorType::LocalizationManager, EditorType::HexEditor, + EditorType::FogDataEditor, EditorType::Unknown, ] } diff --git a/dispel-gui/src/tests/fog_data.rs b/dispel-gui/src/tests/fog_data.rs new file mode 100644 index 00000000..a98ad703 --- /dev/null +++ b/dispel-gui/src/tests/fog_data.rs @@ -0,0 +1,560 @@ +//! State-level integration tests for the fog data editor +//! (`ExtraInGame/fogdata.dat`). +//! +//! Fixture convention: `../fixtures/Dispel`, gracefully skipped when missing +//! (same as `map_editor.rs` / `update/map.rs`). + +#[cfg(test)] +mod fog_data_editor_tests { + use crate::app::App; + use crate::editors::fog_data::{FogDataEditorState, FogDataMessage, handle}; + use crate::message::Message; + use crate::message::system::SystemMessage; + use crate::tests::app_with_tab; + use crate::workspace::{EditorType, Workspace}; + use dispel_core::map::fogdata::{MAX_FACTOR, ROWS}; + use std::path::PathBuf; + + const TAB: usize = 1; // matches app_with_tab's tab id + + /// Fixture fade-table path, or `None` when fixtures are absent. + fn fixture_fogdata_path() -> Option { + let p = PathBuf::from("../fixtures/Dispel/ExtraInGame/fogdata.dat"); + p.exists().then_some(p) + } + + /// An App whose active tab is a FogDataEditor with the fixture loaded. + fn app_with_fixture() -> Option { + let mut app = app_with_tab(EditorType::FogDataEditor); + let path = fixture_fogdata_path()?; + app.state + .editors + .fog_editors + .insert(TAB, FogDataEditorState::load_from_path(&path)); + Some(app) + } + + fn editor_of(app: &App) -> &FogDataEditorState { + app.state.editors.fog_editors.get(&TAB).unwrap() + } + + fn run(app: &mut App, msg: FogDataMessage) { + let _ = handle(msg, app); + } + + // ── Open + parse ────────────────────────────────────────────────────── + + #[test] + fn test_open_fixture_parses_levels_and_spot_factors() { + let Some(app) = app_with_fixture() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + let editor = editor_of(&app); + assert!(editor.error.is_none(), "fixture must parse cleanly"); + assert!(editor.fog.is_some(), "fade tables present"); + assert_eq!(editor.level_count(), ROWS, "123 light levels"); + assert_eq!(editor.current_row().len(), 512, "512 samples per level"); + // Known values from the shipped file. + assert_eq!( + editor.fog.as_ref().unwrap().factor(1, 0), + 2, + "level 1 starts near-black" + ); + assert_eq!( + editor.fog.as_ref().unwrap().factor(65, 256), + 31, + "level 65 mid-curve fully bright" + ); + assert!(!editor.dirty, "freshly opened is clean"); + } + + #[test] + fn test_from_path_routes_dat_fogdata_to_editor() { + let p = PathBuf::from("/game/ExtraInGame/fogdata.dat"); + assert_eq!(EditorType::from_path(&p), EditorType::FogDataEditor); + } + + #[test] + fn test_open_missing_file_surfaces_error_without_panic() { + let state = FogDataEditorState::load_from_path(std::path::Path::new( + "/definitely/not/a/real/fogdata.dat", + )); + assert!(state.error.is_some(), "missing file → error surface"); + assert!(state.fog.is_none()); + // The view must render the error surface without panicking. + let mut app = app_with_tab(EditorType::FogDataEditor); + app.state.editors.fog_editors.insert(TAB, state); + let _ = app.view(); + } + + // ── Painting + dirty flag ───────────────────────────────────────────── + + #[test] + fn test_paint_updates_state_dirty_and_tab_flag() { + let mut app = app_with_fixture().expect("fixture available"); + let before = editor_of(&app).selected_factor(); + + run( + &mut app, + FogDataMessage::FactorPainted(TAB, 100, MAX_FACTOR), + ); + + let editor = editor_of(&app); + assert_eq!(editor.selected_pair, 100, "painting selects the pair"); + assert_eq!( + editor.selected_factor(), + Some(MAX_FACTOR), + "painted value applied" + ); + assert_ne!(editor.selected_factor(), before, "value actually changed"); + assert!(editor.dirty, "paint marks dirty"); + assert!( + app.state.workspace.tabs[0].modified, + "workspace tab flagged modified" + ); + + // Stroke end commits exactly one undo snapshot. + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + assert_eq!(editor_of(&app).undo_stack.len(), 1, "one stroke snapshot"); + assert!(editor_of(&app).can_undo()); + } + + #[test] + fn test_paint_rejects_out_of_range_value() { + let mut app = app_with_fixture().expect("fixture available"); + let before = editor_of(&app).selected_factor(); + + run(&mut app, FogDataMessage::FactorPainted(TAB, 5, 32)); + run(&mut app, FogDataMessage::FactorPainted(TAB, 512, 1)); + + let editor = editor_of(&app); + assert_eq!(editor.selected_factor(), before, "no state change"); + assert!(!editor.dirty, "rejected paints stay clean"); + assert!(editor.undo_stack.is_empty(), "no undo entry created"); + } + + #[test] + fn test_paint_stroke_without_change_creates_no_undo_entry() { + let mut app = app_with_fixture().expect("fixture available"); + let value = editor_of(&app).selected_factor().unwrap(); + let pair = editor_of(&app).selected_pair; + + // Paint the exact same value — a no-op stroke. + run(&mut app, FogDataMessage::FactorPainted(TAB, pair, value)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + + assert!( + editor_of(&app).undo_stack.is_empty(), + "unchanged curve must not push an undo snapshot" + ); + assert!(!editor_of(&app).dirty); + } + + // ── Inspector field ─────────────────────────────────────────────────── + + #[test] + fn test_value_input_validation_shows_inline_error() { + let mut app = app_with_fixture().expect("fixture available"); + + run( + &mut app, + FogDataMessage::ValueInputChanged(TAB, "99".into()), + ); + assert!( + editor_of(&app).input_error.is_some(), + "99 out of range → inline error" + ); + + run( + &mut app, + FogDataMessage::ValueInputChanged(TAB, "abc".into()), + ); + assert!(editor_of(&app).input_error.is_some(), "non-numeric → error"); + + run( + &mut app, + FogDataMessage::ValueInputChanged(TAB, "17".into()), + ); + assert!( + editor_of(&app).input_error.is_none(), + "valid input clears error" + ); + } + + #[test] + fn test_value_submit_commits_and_marks_dirty() { + let mut app = app_with_fixture().expect("fixture available"); + let original = editor_of(&app).selected_factor().unwrap(); + let replacement = if original == 5 { 6 } else { 5 }; + + run( + &mut app, + FogDataMessage::ValueInputChanged(TAB, replacement.to_string()), + ); + run(&mut app, FogDataMessage::ValueSubmitted(TAB)); + + let editor = editor_of(&app); + assert_eq!(editor.selected_factor(), Some(replacement)); + assert!(editor.dirty, "committed edit marks dirty"); + assert_eq!(editor.undo_stack.len(), 1, "commit pushes one snapshot"); + + // Submitting an invalid buffer must be rejected without state change. + run( + &mut app, + FogDataMessage::ValueInputChanged(TAB, "40".into()), + ); + run(&mut app, FogDataMessage::ValueSubmitted(TAB)); + let editor = editor_of(&app); + assert_eq!(editor.selected_factor(), Some(replacement), "unchanged"); + assert!(editor.input_error.is_some()); + assert_eq!(editor.undo_stack.len(), 1, "no extra snapshot"); + } + + #[test] + fn test_factor_committed_rejects_out_of_range() { + let mut app = app_with_fixture().expect("fixture available"); + let before = editor_of(&app).selected_factor(); + + run(&mut app, FogDataMessage::FactorCommitted(TAB, 3, 200)); + + let editor = editor_of(&app); + assert_eq!(editor.selected_factor(), before, "state untouched"); + assert!(!editor.dirty, "rejection stays clean"); + assert!(editor.undo_stack.is_empty()); + } + + #[test] + fn test_level_selected_updates_selection_and_input_buffer() { + let mut app = app_with_fixture().expect("fixture available"); + run(&mut app, FogDataMessage::LevelSelected(TAB, 65)); + assert_eq!(editor_of(&app).selected_level, 65); + assert!( + !editor_of(&app).value_input.is_empty(), + "input buffer refreshed from new level" + ); + + // Out-of-range levels are ignored. + run(&mut app, FogDataMessage::LevelSelected(TAB, 124)); + run(&mut app, FogDataMessage::LevelSelected(TAB, 0)); + assert_eq!(editor_of(&app).selected_level, 65, "clamped to valid range"); + } + + // ── Undo / redo round-trip ──────────────────────────────────────────── + + #[test] + fn test_undo_redo_round_trip_restores_prior_curve() { + let mut app = app_with_fixture().expect("fixture available"); + let original = editor_of(&app).fog.as_ref().unwrap().clone(); + + // Stroke A: paint pair 10. + run(&mut app, FogDataMessage::FactorPainted(TAB, 10, 1)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + let after_a = editor_of(&app).fog.as_ref().unwrap().clone(); + + // Stroke B: paint pair 20 with a value guaranteed to differ from + // whatever the fixture holds there. + let cur_b = editor_of(&app).fog.as_ref().unwrap().factor(1, 20); + let new_b = (cur_b + 7) % MAX_FACTOR; + run(&mut app, FogDataMessage::FactorPainted(TAB, 20, new_b)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + assert_ne!(editor_of(&app).fog.as_ref().unwrap(), &after_a); + + // Undo B → back to after-A. + run(&mut app, FogDataMessage::Undo(TAB)); + assert_eq!( + editor_of(&app).fog.as_ref().unwrap(), + &after_a, + "first undo restores prior stroke" + ); + + // Undo A → back to the pristine table. + run(&mut app, FogDataMessage::Undo(TAB)); + assert_eq!( + editor_of(&app).fog.as_ref().unwrap(), + &original, + "second undo restores the original curve" + ); + assert!(!editor_of(&app).can_undo()); + + // Nothing left to undo reports via status bar. + run(&mut app, FogDataMessage::Undo(TAB)); + assert_eq!(app.state.status_msg, "Nothing to undo"); + + // Redo reapplies both strokes in order. + run(&mut app, FogDataMessage::Redo(TAB)); + assert_eq!(editor_of(&app).fog.as_ref().unwrap(), &after_a); + run(&mut app, FogDataMessage::Redo(TAB)); + assert_ne!( + editor_of(&app).fog.as_ref().unwrap(), + &original, + "redo restores edits" + ); + assert!(editor_of(&app).dirty); + } + + #[test] + fn test_registry_undo_redo_returns_none_with_empty_history() { + use std::collections::HashMap; + + let mut registry = crate::editor_registry::EditorRegistry::default(); + let lookups: HashMap> = HashMap::new(); + + assert!( + registry + .undo_active(EditorType::FogDataEditor, TAB, &lookups) + .is_none(), + "no tab state → nothing to undo" + ); + assert!( + registry + .redo_active(EditorType::FogDataEditor, TAB, &lookups) + .is_none() + ); + + // With a tab present but empty stacks it must still report None. + registry + .fog_editors + .insert(TAB, FogDataEditorState::default()); + assert!( + registry + .undo_active(EditorType::FogDataEditor, TAB, &lookups) + .is_none(), + "empty undo stack → None (so the status bar says 'Nothing to undo')" + ); + } + + // ── Save round-trip ─────────────────────────────────────────────────── + + #[test] + fn test_save_writes_bytes_identical_to_original_when_unchanged() { + let Some(path) = fixture_fogdata_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + let original_bytes = std::fs::read(&path).expect("fixture readable"); + + // Copy into a temp dir and open the copy. + let dir = std::env::temp_dir().join(format!("dispel-fog-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir created"); + let copy = dir.join("fogdata.dat"); + std::fs::write(©, &original_bytes).expect("copy written"); + + let state = FogDataEditorState::load_from_path(©); + assert!(state.error.is_none()); + crate::editors::fog_data::save_to_disk(©, state.fog.as_ref().unwrap()) + .expect("save succeeds"); + + let saved = std::fs::read(©).expect("saved file readable"); + assert_eq!( + saved, original_bytes, + "unmodified table round-trips byte-identical" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_save_message_dispatches_and_complete_marks_clean() { + let mut app = app_with_fixture().expect("fixture available"); + run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + assert!(editor_of(&app).dirty); + + // Save returns an async task; simulate its completion. + let save_gen = editor_of(&app).save_generation; + run( + &mut app, + FogDataMessage::SaveComplete(TAB, save_gen, Ok(("Saved → somewhere".into(), None))), + ); + assert!(!editor_of(&app).dirty, "successful save clears dirty"); + assert!(!app.state.workspace.tabs[0].modified); + assert_eq!(app.state.status_msg, "Saved → somewhere"); + + // A failed save keeps the editor dirty. + run(&mut app, FogDataMessage::FactorPainted(TAB, 8, 10)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + let save_gen = editor_of(&app).save_generation; + run( + &mut app, + FogDataMessage::SaveComplete(TAB, save_gen, Err("disk on fire".into())), + ); + assert!(editor_of(&app).dirty, "failed save stays dirty"); + assert!(app.state.workspace.tabs[0].modified); + assert!( + app.state.status_msg.contains("disk on fire"), + "failure reported via status bar" + ); + } + + #[test] + fn test_save_completion_with_stale_generation_keeps_tab_dirty() { + let mut app = app_with_fixture().expect("fixture available"); + run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + assert!(editor_of(&app).dirty); + + // Save dispatch bumps the generation; simulate the completion + // carrying the pre-dispatch value (an edit landed while saving). + let stale_gen = editor_of(&app).save_generation - 1; + run( + &mut app, + FogDataMessage::SaveComplete(TAB, stale_gen, Ok(("Saved → x".into(), None))), + ); + + assert!( + editor_of(&app).dirty, + "stale save completion must not clear dirty" + ); + assert!(app.state.workspace.tabs[0].modified, "tab stays modified"); + + // A current-generation completion still marks clean. + let save_gen = editor_of(&app).save_generation; + run( + &mut app, + FogDataMessage::SaveComplete(TAB, save_gen, Ok(("Saved → x".into(), None))), + ); + assert!(!editor_of(&app).dirty); + } + + #[test] + fn test_save_complete_recording_failure_reports_both_facts_and_marks_clean() { + let mut app = app_with_fixture().expect("fixture available"); + run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + + let save_gen = editor_of(&app).save_generation; + run( + &mut app, + FogDataMessage::SaveComplete( + TAB, + save_gen, + Ok(("Saved → x".into(), Some("changelog locked".into()))), + ), + ); + + let status = &app.state.status_msg; + assert!(status.contains("Saved"), "file-saved success is reported"); + assert!( + status.contains("Mod recording failed") && status.contains("changelog locked"), + "recording failure reported separately: {status}" + ); + assert!( + !editor_of(&app).dirty, + "file on disk is current → editor marked clean" + ); + assert!(!app.state.workspace.tabs[0].modified); + } + + #[test] + fn test_system_save_respects_capability() { + let mut app = app_with_tab(EditorType::FogDataEditor); + let _ = app.update(Message::System(SystemMessage::Save)); + assert_ne!( + app.state.status_msg, "This editor does not support saving", + "Ctrl+S must dispatch to the fog data editor" + ); + } + + // ── Revert ──────────────────────────────────────────────────────────── + + #[test] + fn test_revert_discards_changes_and_restores_disk_state() { + let Some(path) = fixture_fogdata_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + let original_bytes = std::fs::read(&path).expect("fixture readable"); + let dir = std::env::temp_dir().join(format!("dispel-fog-revert-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir created"); + let copy = dir.join("fogdata.dat"); + std::fs::write(©, &original_bytes).expect("copy written"); + + let mut app = app_with_tab(EditorType::FogDataEditor); + app.state + .editors + .fog_editors + .insert(TAB, FogDataEditorState::load_from_path(©)); + + // Dirty the copy. + run(&mut app, FogDataMessage::FactorPainted(TAB, 42, 30)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + assert!(editor_of(&app).dirty); + + // Dirty revert asks for confirmation first. + run(&mut app, FogDataMessage::Revert(TAB)); + assert!(editor_of(&app).confirm_revert, "confirm dialog opens"); + assert!(editor_of(&app).dirty, "nothing discarded yet"); + + run(&mut app, FogDataMessage::RevertConfirmed(TAB)); + let editor = editor_of(&app); + assert!(!editor.dirty, "revert restores clean state"); + assert!(!editor.confirm_revert); + assert!(editor.undo_stack.is_empty(), "history cleared"); + assert_eq!( + std::fs::read(©).unwrap(), + original_bytes, + "disk file untouched by revert" + ); + + // Clean revert reloads immediately without any dialog. + run(&mut app, FogDataMessage::Revert(TAB)); + assert!(!editor_of(&app).confirm_revert); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_revert_cancelled_keeps_edits() { + let mut app = app_with_fixture().expect("fixture available"); + run(&mut app, FogDataMessage::FactorPainted(TAB, 11, 3)); + run(&mut app, FogDataMessage::StrokeEnded(TAB)); + + run(&mut app, FogDataMessage::Revert(TAB)); + assert!(editor_of(&app).confirm_revert); + + run(&mut app, FogDataMessage::RevertCancelled(TAB)); + let editor = editor_of(&app); + assert!(!editor.confirm_revert); + assert!(editor.dirty, "edits preserved after cancel"); + assert_eq!(editor.selected_factor(), Some(3), "painted value kept"); + } + + // ── Workspace lifecycle ─────────────────────────────────────────────── + + #[test] + fn test_remove_tab_clears_fog_editor_state() { + let mut registry = crate::editor_registry::EditorRegistry::default(); + registry + .fog_editors + .insert(TAB, FogDataEditorState::default()); + + registry.remove_tab(TAB); + assert!(!registry.fog_editors.contains_key(&TAB), "tab cleanup"); + + registry + .fog_editors + .insert(2, FogDataEditorState::default()); + registry.close_all_tabs(); + assert!(registry.fog_editors.is_empty(), "close_all_tabs cleanup"); + + registry + .fog_editors + .insert(3, FogDataEditorState::default()); + registry.clear_all(); + assert!(registry.fog_editors.is_empty(), "clear_all cleanup"); + } + + #[test] + fn test_workspace_reset_clears_open_fog_tabs() { + let mut app = App::test_new(Workspace::new()); + app.state + .editors + .fog_editors + .insert(TAB, Default::default()); + app.state.editors.clear_all(); + assert!(app.state.editors.fog_editors.is_empty()); + } +} diff --git a/dispel-gui/src/tests/map_editor.rs b/dispel-gui/src/tests/map_editor.rs index 2f66f3f4..476c371c 100644 --- a/dispel-gui/src/tests/map_editor.rs +++ b/dispel-gui/src/tests/map_editor.rs @@ -1,7 +1,9 @@ #[cfg(test)] mod map_editor_entity_tests { use crate::app::App; + use crate::components::map_render::traits::MapRenderSource; use crate::editors::map_editor; + use crate::editors::map_editor::message::MapLayer; use crate::editors::map_editor::{ MapEditAction, MapEditorMessage, MapEditorState, SelectedEntity, }; @@ -593,4 +595,108 @@ mod map_editor_entity_tests { "first redo: 100 → 150" ); } + + // ── Shadow/lighting layer ────────────────────────────────────────────── + + /// Fixture game directory (same convention as update/map.rs tests). + fn fixture_game_path() -> Option { + let p = PathBuf::from("../fixtures/Dispel"); + p.exists().then_some(p) + } + + #[test] + fn shadows_layer_defaults_visible() { + let state = MapEditorState::default(); + assert!( + state.view.show_shadows, + "shadow pass enabled by default, matching observed behavior" + ); + } + + #[test] + fn layer_toggle_shadows_flips_visibility_and_back() { + let mut app = app_with_map_editor(); + let tab_id = 0; + let before = app.state.editors.map_editors[&tab_id].view.show_shadows; + + let task = map_editor::handle( + MapEditorMessage::LayerToggled(tab_id, MapLayer::Shadows), + &mut app, + ); + assert_eq!(task.units(), 0); + let after = app.state.editors.map_editors[&tab_id].view.show_shadows; + assert_ne!(before, after, "LayerToggled(Shadows) flips visibility"); + + // Toggling again restores the original state. + let _ = map_editor::handle( + MapEditorMessage::LayerToggled(tab_id, MapLayer::Shadows), + &mut app, + ); + assert_eq!( + app.state.editors.map_editors[&tab_id].view.show_shadows, before, + "second toggle restores original visibility" + ); + } + + #[test] + fn light_map_loads_no_shadow_data() { + let Some(gp) = fixture_game_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + // cat1 is flagged Light (0) in AllMap.ini → no lighting pass + // is applied there. + assert!( + dispel_core::map::render::load_fog_if_dark(&gp, "cat1").is_none(), + "Light maps must not load shadow data" + ); + } + + #[test] + fn dark_map_loads_fog_data_with_known_factors() { + let Some(gp) = fixture_game_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + // final is flagged Dark (1) in AllMap.ini. + let fog = dispel_core::map::render::load_fog_if_dark(&gp, "final") + .expect("Dark map must load fogdata.dat"); + // Known values from the shipped ExtraInGame/fogdata.dat: level 1 is + // uniformly near-black (f=2), level 65 fully bright (f=31). + assert_eq!(fog.factor(1, 0), 2); + assert_eq!(fog.factor(65, 256), 31); + } + + #[test] + fn unknown_map_loads_no_shadow_data() { + let Some(gp) = fixture_game_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + assert!( + dispel_core::map::render::load_fog_if_dark(&gp, "not_a_map").is_none(), + "maps missing from AllMap.ini get no shadow pass" + ); + } + + #[test] + fn map_render_source_exposes_loaded_shadow_data() { + let Some(gp) = fixture_game_path() else { + eprintln!("Skipping: fixtures not found"); + return; + }; + + let mut state = MapEditorState::default(); + assert!( + state.shadow_data().is_none(), + "no shadow data before a Dark map is loaded" + ); + + let fog = dispel_core::map::render::FogData::load(&gp).expect("fixture fogdata.dat loads"); + state.data.shadow_fog = Some(std::sync::Arc::new(fog)); + assert!( + state.shadow_data().is_some(), + "MapRenderSource::shadow_data surfaces loaded tables to the canvas" + ); + } } diff --git a/dispel-gui/src/tests/mod.rs b/dispel-gui/src/tests/mod.rs index cba6180b..d9cdb033 100644 --- a/dispel-gui/src/tests/mod.rs +++ b/dispel-gui/src/tests/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod editor_field_edit; pub(crate) mod editor_registry; pub(crate) mod error_dialog; pub(crate) mod file_tree; +pub(crate) mod fog_data; pub(crate) mod generic_editor_edge; pub(crate) mod global_search; pub(crate) mod hex_inspector_toggle; diff --git a/dispel-gui/src/tests/save_dispatch.rs b/dispel-gui/src/tests/save_dispatch.rs index c98d79cb..b1bf018d 100644 --- a/dispel-gui/src/tests/save_dispatch.rs +++ b/dispel-gui/src/tests/save_dispatch.rs @@ -70,6 +70,7 @@ mod save_dispatch_tests { EditorType::ModPackager, EditorType::LocalizationManager, EditorType::HexEditor, + EditorType::FogDataEditor, EditorType::Unknown, ] } diff --git a/dispel-gui/src/tests/view_dispatch.rs b/dispel-gui/src/tests/view_dispatch.rs index 8fe95659..4375fa4e 100644 --- a/dispel-gui/src/tests/view_dispatch.rs +++ b/dispel-gui/src/tests/view_dispatch.rs @@ -44,6 +44,7 @@ mod view_dispatch_tests { ModPackager, LocalizationManager, HexEditor, + FogDataEditor, ]; for et in types { diff --git a/dispel-gui/src/update/system.rs b/dispel-gui/src/update/system.rs index a18798c0..ce8474af 100644 --- a/dispel-gui/src/update/system.rs +++ b/dispel-gui/src/update/system.rs @@ -79,6 +79,11 @@ fn save_task_for_editor( Message::sprite_viewer(crate::editors::sprite_editor::SpriteViewerMessage::Save) } + // Fog data editor (per-tab Save carries the tab id) + EditorType::FogDataEditor => { + Message::fog_data(crate::editors::fog_data::FogDataMessage::Save(tab_id)) + } + // Map editor uses SaveEntities with tab_id EditorType::MapEditor => Message::map_editor(MapEditorMessage::SaveEntities(tab_id)), diff --git a/dispel-gui/src/workspace.rs b/dispel-gui/src/workspace.rs index a106954c..db12f74b 100644 --- a/dispel-gui/src/workspace.rs +++ b/dispel-gui/src/workspace.rs @@ -51,6 +51,9 @@ pub enum EditorType { SaveFileViewer, /// Save-slot manager for `Save.ifo`: swap slots and edit global tail fields. SaveIfoEditor, + /// Editor for `ExtraInGame/fogdata.dat` — map-lighting fade tables + /// (123 levels × 512 brightness factors). + FogDataEditor, /// Universal fallback editor for any binary file no dedicated editor /// claims. Also reachable via "Open as Hex" from the file tree. HexEditor, @@ -132,6 +135,8 @@ impl EditorType { // .scr files (exact stems only; "event*" prefix handled separately) m.insert(("scr", "quest"), EditorType::QuestScrEditor); m.insert(("scr", "message"), EditorType::MessageScrEditor); + // .dat files (exact stems only) + m.insert(("dat", "fogdata"), EditorType::FogDataEditor); m }) } @@ -183,10 +188,12 @@ impl EditorType { !matches!( self, EditorType::EventScrEditor - | EditorType::MonsterEditor + | EditorType::MonsterEditor | EditorType::MonsterIniEditor | EditorType::NpcIniEditor + // Snapshot-based undo (own stacks, no EditHistory component) | EditorType::SpriteViewer + | EditorType::FogDataEditor | EditorType::SnfEditor | EditorType::DbViewer | EditorType::TilesetEditor diff --git a/docs/files/CROSS_REFERENCES.md b/docs/files/CROSS_REFERENCES.md index 74e8dd6d..31a46fcf 100644 --- a/docs/files/CROSS_REFERENCES.md +++ b/docs/files/CROSS_REFERENCES.md @@ -37,6 +37,7 @@ files form a complex interconnected database that drives the game's mechanics, v - **Eventnpc.ref** (NpcInGame/): Event-triggered NPC definitions - **NpcMapFiles.ref** (NpcInGame/): NPC placements with waypoints - **Extra.ref** (ExtraInGame/): Interactive object placements +- **fogdata.dat** (ExtraInGame/): Map lighting fade tables, consumed by the map renderer/editor (see [ExtraInGame/fogdata.dat.md](ExtraInGame/fogdata.dat.md)) - **MondunMonmap.ref** (MonsterInGame/): Monster placements on maps ### Script Files (SCR/DLG/PGP) diff --git a/docs/files/ExtraInGame/fogdata.dat.md b/docs/files/ExtraInGame/fogdata.dat.md new file mode 100644 index 00000000..6cf06f46 --- /dev/null +++ b/docs/files/ExtraInGame/fogdata.dat.md @@ -0,0 +1,98 @@ +# ExtraInGame/fogdata.dat Documentation + +> DISPEL® is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. + +## File Information + +### Overview + +Map-lighting fade tables. The file is a flat array of brightness factors used +to shade tiles that carry a shadow (darkness) level. Each row of the table is one +animated flicker pattern for a single light level; the renderer walks the row per rendered frame +to make torch-lit darkness shimmer. + +This file feeds the map lighting pass, which only runs on maps flagged **Dark** in `AllMap.ini` +(see `docs/rendering.md` and `src/map/render.rs::plot_shadows`). + +### File Structure + +**Location**: `ExtraInGame/fogdata.dat` (single file, shared by all maps) +**Encoding**: Binary (raw bytes, no header) +**Total Size**: 62,976 bytes = 123 rows × 512 bytes + +### Format Specification + +``` +[Row 1] – serves light level 1 +- factor: 512 × u8 (one brightness factor per pixel pair) + +[Row 2] – serves light level 2 +... (same structure) ... + +[Row 123] – serves light level 123 +``` + +| Property | Value | +|-------------|--------------------------| +| Rows | 123 | +| Row length | 512 bytes | +| Total | 62,976 bytes | +| Header | None | +| Endianness | N/A (byte-granular data) | + +### Level → Row Mapping + +Row `L-1` serves light level `L`. A consumer indexes the file as: + +``` +byte[(level - 1) * 512 + pair] +``` + +where `pair` is the pixel-pair index (`0..512`) within a shadowed tile — each factor byte covers +two horizontally adjacent pixels. + +**Range nuance:** light levels `1..=199` occur in the map's access-ref grid (levels ≥ 200 are +skipped by the lighting pass entirely), but the file only covers levels `1..=123`. +Levels above 123 would read past the table; tooling must clamp or skip. + +### Factor Semantics + +Each byte is a brightness factor `f` in `0..=31` — a 5-bit fixed-point value: + +- Effective multiplier on shadowed pixels: `f / 32`. +- Applied to the **red and green channels only**; blue stays untouched. +- `f = 0` → pixel pair blacked out; `f = 31` → nearly full brightness. +- Values are **not monotonic in level**: each row is an animated flicker pattern, so adjacent + levels can have very different brightness at the same pair index. + +### File Purpose + +Provides the per-level darkness fade/flicker patterns that the map lighting pass applies to +shadowed tiles on Dark maps, producing an animated torch-light falloff. + +### Cross-References + +| Consumer | References | +|---------------------|-------------------------------------------------------------------| +| Map lighting pass | `src/map/render.rs` (`plot_shadows`, `FogData`) | +| Shadow levels | `.map` access-ref grid bits 15–29 (`src/map/mod.rs::shadow_levels`) | +| Dark flag | `AllMap.ini` (`lighting == Dark` per map) | + +See also `docs/rendering.md` (isometric rendering pipeline) and +`docs/files/CROSS_REFERENCES.md`. + +### Technical Details + +- No header, no padding — exactly 62,976 raw bytes. +- Byte order is irrelevant (single-byte fields). +- Parsed by `dispel_core::map::fogdata::FogData` (re-exported as + `dispel_core::map::render::FogData`), which validates the length and provides + bounds-checked accessors plus an editor-safe `set_factor` that rejects values > 31 + (out-of-range values wrap when consumed). + +### Notes + +- Level 0 tiles are not looked up here: on Dark maps they are blacked out outright. +- When editing, keep every byte in `0..=31`; larger values are invalid in this table even though + they wrap silently when consumed. diff --git a/src/cli.rs b/src/cli.rs index 14526fb0..7d2ed6b3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -233,6 +233,10 @@ pub enum MapCommands { /// Disable roof (BTL) tile layer #[arg(long)] no_roofs: bool, + /// Disable the shadow/lighting pass (fog-of-war darkening on maps + /// flagged Dark in AllMap.ini) + #[arg(long)] + no_shadows: bool, /// Disable internal sprites (embedded in .map file) #[arg(long)] no_internal_sprites: bool, @@ -247,9 +251,6 @@ pub enum MapCommands { no_objects: bool, // Canvas mode - /// Render the full map canvas instead of the occluded viewport (reveals all edges) - #[arg(long)] - full_map: bool, /// Output RGBA PNG with alpha channel (black background pixels become transparent) #[arg(long)] transparent: bool, diff --git a/src/commands/map.rs b/src/commands/map.rs index c69075a0..3215e1f6 100644 --- a/src/commands/map.rs +++ b/src/commands/map.rs @@ -43,11 +43,11 @@ impl Command for MapCommand { no_ground, no_buildings, no_roofs, + no_shadows, no_internal_sprites, no_monsters, no_npcs, no_objects, - full_map, transparent, collisions, events, @@ -66,11 +66,11 @@ impl Command for MapCommand { show_ground: !no_ground, show_buildings: !no_buildings, show_roofs: !no_roofs, + show_shadows: !no_shadows, show_internal_sprites: !no_internal_sprites, show_monsters: !no_monsters, show_npcs: !no_npcs, show_objects: !no_objects, - full_map: *full_map, transparent: *transparent, show_collisions: *collisions, show_events: *events, diff --git a/src/map/fogdata.rs b/src/map/fogdata.rs new file mode 100644 index 00000000..3d766d75 --- /dev/null +++ b/src/map/fogdata.rs @@ -0,0 +1,278 @@ +//! Parser for `ExtraInGame/fogdata.dat` — observed map-lighting fade tables. +//! +//! The file is a flat table of exactly [`ROWS`] (123) rows × [`ROW_LEN`] +//! (512) bytes = 62,976 bytes total, with no header. +//! +//! Row `L-1` serves light level `L`. Map data carries levels `1..=199`, +//! but the table only covers levels `1..=123`; levels beyond that index +//! past the table (callers must clamp or skip). +//! +//! Each byte is a brightness factor `f` in `0..=[MAX_FACTOR]` (31), i.e. a +//! 5-bit fixed-point value: the effective multiplier on shadowed tile pixels +//! is `f/32`, applied to the **red and green channels only** (blue stays +//! untouched). Rows are animated flicker patterns — values are *not* +//! monotonic in level. +//! +//! A consumer indexes the file as `byte[(level-1)*512 + pair]` where `pair` +//! is the pixel-pair index (`0..512`) within a tile; each factor byte covers +//! two horizontally adjacent pixels. +//! +//! This data feeds the map lighting pass for maps flagged Dark in +//! `AllMap.ini`; see [`super::render::plot_shadows`] and +//! `docs/rendering.md`. + +use std::fmt; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Number of fade-table rows (one per light level). +pub const ROWS: usize = 123; +/// Bytes per row (one brightness factor per pixel pair). +pub const ROW_LEN: usize = 512; +/// Total expected file size in bytes. +pub const EXPECTED_LEN: usize = ROWS * ROW_LEN; +/// Maximum valid brightness factor (5-bit fixed point). +pub const MAX_FACTOR: u8 = 31; + +/// Errors returned by [`FogData::set_factor`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetFactorError { + /// Factor values above 31 are rejected: out-of-range values wrap when + /// consumed, and a modding tool refuses them instead. + ValueOutOfRange(u8), + /// Light level outside `1..=[ROWS]`. + LevelOutOfRange(u32), + /// Pixel-pair index outside `0..[ROW_LEN]`. + PairOutOfRange(usize), +} + +impl fmt::Display for SetFactorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ValueOutOfRange(v) => { + write!(f, "factor {v} exceeds MAX_FACTOR ({MAX_FACTOR})") + } + Self::LevelOutOfRange(l) => write!(f, "level {l} out of range 1..={ROWS}"), + Self::PairOutOfRange(p) => write!(f, "pair {p} out of range 0..{ROW_LEN}"), + } + } +} + +impl std::error::Error for SetFactorError {} + +/// In-memory representation of `ExtraInGame/fogdata.dat`. +/// +/// See the [module documentation](self) for the binary layout and semantics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FogData { + data: Vec, +} + +impl FogData { + /// Parses the fade tables from a reader positioned at the start of a + /// `fogdata.dat` stream. + /// + /// Reads exactly [`EXPECTED_LEN`] bytes; short streams are rejected with + /// `InvalidData`. Extra trailing bytes are tolerated (callers normally + /// pass exact-length streams). + /// + /// # Seek behavior + /// The reader is rewound to offset 0 before reading, regardless of its + /// current position. + pub fn parse(reader: &mut R) -> std::io::Result { + reader.seek(SeekFrom::Start(0))?; + let mut data = vec![0u8; EXPECTED_LEN]; + reader.read_exact(&mut data).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("fogdata.dat too short: expected {EXPECTED_LEN} bytes ({e})"), + ) + })?; + Ok(Self { data }) + } + + /// Loads the fade tables from `/ExtraInGame/fogdata.dat`. + pub fn load(game_path: &Path) -> std::io::Result { + let path = game_path.join("ExtraInGame").join("fogdata.dat"); + if path.metadata()?.len() < EXPECTED_LEN as u64 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "fogdata.dat too small: expected at least {} bytes", + EXPECTED_LEN + ), + )); + } + let mut file = File::open(&path)?; + Self::parse(&mut file) + } + + /// Writes all 62,976 raw bytes to `w`. + pub fn to_writer(&self, w: &mut W) -> std::io::Result<()> { + w.write_all(&self.data) + } + + /// Saves the fade tables to `path`, creating or truncating the file. + pub fn save_file(&self, path: &Path) -> std::io::Result<()> { + let mut file = File::create(path)?; + self.to_writer(&mut file) + } + + /// Fade factor for light `level` (1-based) at pixel-pair index `pair` + /// (0..512). + /// + /// # Panics + /// Panics when `level == 0`, `level > ROWS`, or `pair >= ROW_LEN`. + /// Hot-path accessor — use [`FogData::get_factor`] for a panic-free + /// variant. + pub fn factor(&self, level: u32, pair: usize) -> u8 { + self.data[((level - 1) as usize) * ROW_LEN + pair] + } + + /// Panic-free variant of [`FogData::factor`]; returns `None` when + /// `level` or `pair` is out of range. + pub fn get_factor(&self, level: u32, pair: usize) -> Option { + if level == 0 || level as usize > ROWS || pair >= ROW_LEN { + return None; + } + Some(self.factor(level, pair)) + } + + /// Sets the fade factor for light `level` at pixel-pair index `pair`. + /// + /// # Errors + /// Returns [`SetFactorError::ValueOutOfRange`] when `value > MAX_FACTOR` + /// (out-of-range values wrap when consumed, so editors must enforce the + /// range here), [`SetFactorError::LevelOutOfRange`] when + /// `level == 0 || level > ROWS`, or [`SetFactorError::PairOutOfRange`] + /// when `pair >= ROW_LEN`. + pub fn set_factor(&mut self, level: u32, pair: usize, value: u8) -> Result<(), SetFactorError> { + if value > MAX_FACTOR { + return Err(SetFactorError::ValueOutOfRange(value)); + } + if level == 0 || level as usize > ROWS { + return Err(SetFactorError::LevelOutOfRange(level)); + } + if pair >= ROW_LEN { + return Err(SetFactorError::PairOutOfRange(pair)); + } + self.data[((level - 1) as usize) * ROW_LEN + pair] = value; + Ok(()) + } + + /// The full 512-byte row serving light `level`. + /// + /// # Panics + /// Panics when `level == 0` or `level > ROWS`. + pub fn row(&self, level: u32) -> &[u8] { + let start = ((level - 1) as usize) * ROW_LEN; + &self.data[start..start + ROW_LEN] + } + + /// Total byte length (always [`EXPECTED_LEN`]). + pub fn len(&self) -> usize { + self.data.len() + } + + /// Always `false` — a parsed table always holds [`EXPECTED_LEN`] bytes. + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + /// Test-only constructor from raw bytes (sibling-module tests included). + #[cfg(test)] + pub(crate) fn from_raw(data: Vec) -> Self { + debug_assert_eq!(data.len(), EXPECTED_LEN); + Self { data } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_parse_rejects_short_buffer() { + let buf = vec![0u8; 100]; + let err = FogData::parse(&mut Cursor::new(buf)).expect_err("short buffer must be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn test_parse_and_factor_spot_checks() { + let mut data = vec![0u8; EXPECTED_LEN]; + for level in 1..=ROWS as u32 { + for p in 0..ROW_LEN { + data[((level - 1) as usize) * ROW_LEN + p] = ((level * 7 + p as u32) % 32) as u8; + } + } + let fog = FogData::parse(&mut Cursor::new(data)).unwrap(); + + assert_eq!(fog.factor(1, 0), 7); + assert_eq!(fog.factor(2, 0), 14); + assert_eq!(fog.factor(123, 0), ((123 * 7) % 32) as u8); + assert_eq!(fog.factor(123, 511), ((123 * 7 + 511) % 32) as u8); + assert_eq!(fog.factor(65, 256), ((65 * 7 + 256) % 32) as u8); + } + + #[test] + fn test_round_trip_is_byte_identical() { + let mut data = vec![0u8; EXPECTED_LEN]; + for (i, b) in data.iter_mut().enumerate() { + *b = (i % 251) as u8; + } + let fog = FogData::parse(&mut Cursor::new(data.clone())).unwrap(); + let mut out = Vec::new(); + fog.to_writer(&mut out).unwrap(); + assert_eq!(out, data); + } + + #[test] + fn test_set_factor_valid_write_and_rejections() { + let mut fog = FogData::from_raw(vec![0u8; EXPECTED_LEN]); + + fog.set_factor(4, 10, 17).unwrap(); + assert_eq!(fog.factor(4, 10), 17); + + assert_eq!( + fog.set_factor(1, 0, 32), + Err(SetFactorError::ValueOutOfRange(32)) + ); + assert_eq!( + fog.set_factor(0, 0, 1), + Err(SetFactorError::LevelOutOfRange(0)) + ); + assert_eq!( + fog.set_factor(124, 0, 1), + Err(SetFactorError::LevelOutOfRange(124)) + ); + assert_eq!( + fog.set_factor(1, 512, 1), + Err(SetFactorError::PairOutOfRange(512)) + ); + } + + #[test] + fn test_get_factor_range_checks() { + let fog = FogData::from_raw(vec![7u8; EXPECTED_LEN]); + assert_eq!(fog.get_factor(1, 0), Some(7)); + assert_eq!(fog.get_factor(123, 511), Some(7)); + assert_eq!(fog.get_factor(0, 0), None); + assert_eq!(fog.get_factor(124, 0), None); + assert_eq!(fog.get_factor(1, 512), None); + } + + #[test] + fn test_row_matches_factor_samples() { + let fog = FogData::from_raw(vec![3u8; EXPECTED_LEN]); + let row = fog.row(5); + assert_eq!(row.len(), ROW_LEN); + for p in [0usize, 1, 255, 256, 511] { + assert_eq!(row[p], fog.factor(5, p)); + } + } +} diff --git a/src/map/mod.rs b/src/map/mod.rs index 08729589..8f5e1d43 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -100,6 +100,7 @@ // Total size = header + blocks + width×height×12 (three packed-u32 grids) // pub mod database; +pub mod fogdata; pub mod model; pub mod reader; pub mod render; @@ -136,7 +137,7 @@ use serde::{Deserialize, Serialize}; type IoResult = std::io::Result; use reader::{ - read_events_block, read_roof_tiles, read_tiles_and_access_block, skip_overlay_id_table, + read_events_block, read_overlay_id_table, read_roof_tiles, read_tiles_and_access_block, skip_tiled_object_refs, sprite_block, sprite_info_block, tiled_objects_block, }; use render::{MapRenderConfig, render_map}; @@ -149,6 +150,9 @@ pub struct MapData { pub model: MapModel, pub gtl_tiles: HashMap, pub btl_tiles: HashMap, + /// Per-overlay-id `{lo: transparency mode, hi: draw-enable}` table from + /// the map file; drives roof rendering (see `render::plot_roofs`). + pub overlay_modes: Vec, /// Raw packed u32 words of the access-ref grid ("roof" block), preserved /// so saving keeps shadow levels and light flags intact. Overlay refs are /// patched from `btl_tiles` on write. @@ -244,6 +248,22 @@ pub struct SpriteFrameJson { } impl MapData { + /// Iterate tiles carrying a light level, yielding `(coords, level)` with + /// levels in `1..=199`. + /// + /// The level lives in bits 15–29 of the tile's access-ref word and + /// selects a brightness pattern from `ExtraInGame/fogdata.dat` (higher + /// levels are generally brighter, not darker). Level 0 tiles are skipped + /// here — on maps flagged Dark in AllMap.ini they render fully black; + /// see [`render::plot_shadows`]. Levels ≥ 200 fall outside the lighting + /// pass and are skipped as well. + pub fn shadow_levels(&self) -> impl Iterator + '_ { + self.access_ref_words.iter().filter_map(|(&coords, &word)| { + let level = (word >> 15) & 0x7FFF; + (level != 0 && level < 200).then_some((coords, level as u8)) + }) + } + /// Convert MapData to JSON-serializable format. pub fn to_json(&self) -> MapDataJson { MapDataJson { @@ -378,7 +398,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { let tiled_map_height = map_model.tiled_map_height; skip_tiled_object_refs(reader)?; - skip_overlay_id_table(reader)?; + let overlay_modes = read_overlay_id_table(reader)?; let internal_sprites = sprite_block(reader)?; let sprite_blocks = sprite_info_block(reader, &internal_sprites)?; @@ -418,6 +438,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { let (refs, words) = read_roof_tiles(reader, tiled_map_width, tiled_map_height)?; btl_tiles = refs; access_ref_words = words; + let _ = &overlay_modes; } else if remaining_bytes > 0 { // If there are remaining bytes but not enough for a full grid, // this might indicate a different file structure or corruption @@ -431,6 +452,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { model: map_model, gtl_tiles, btl_tiles, + overlay_modes, access_ref_words, collisions, events, @@ -511,7 +533,6 @@ pub fn extract( reader: &mut reader, output_path, data: &map_data, - occlusion: !toggles.full_map, gtl_tileset: >l_tileset, btl_tileset: &btl_tileset, map_id, @@ -854,3 +875,42 @@ pub fn save_map_metadata(conn: &mut Connection, map_id: &str, model: &MapModel) Ok(()) } + +#[cfg(test)] +mod shadow_tests { + use super::*; + + #[test] + fn test_shadow_levels_decodes_bits_15_to_29() { + let mut data = MapData { + model: MapModel::default(), + gtl_tiles: HashMap::new(), + btl_tiles: HashMap::new(), + overlay_modes: Vec::new(), + access_ref_words: HashMap::new(), + collisions: HashMap::new(), + events: HashMap::new(), + object_ids: HashMap::new(), + tiled_infos: Vec::new(), + internal_sprites: Vec::new(), + sprite_blocks: Vec::new(), + }; + + // No shadows yet + assert_eq!(data.shadow_levels().count(), 0); + + // Overlay ref 5 with shadow level 100, plus light-flag bits set high + data.access_ref_words + .insert((1, 0), (100 << 15) | 5 | 0xC000_0000); + // Level 0 → not yielded + data.access_ref_words.insert((2, 0), 7); + // Level 200 → not a darkness level, skipped + data.access_ref_words.insert((3, 0), 200 << 15); + // Level 199 → maximum valid darkness + data.access_ref_words.insert((4, 0), 199 << 15); + + let mut levels: Vec<(Coords, u8)> = data.shadow_levels().collect(); + levels.sort(); + assert_eq!(levels, vec![((1, 0), 100), ((4, 0), 199)]); + } +} diff --git a/src/map/reader.rs b/src/map/reader.rs index 2e5862e0..56ab1c0e 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -50,11 +50,17 @@ pub fn skip_tiled_object_refs(reader: &mut BufReader) -> Result<()> { /// Indexed by the Access-Ref grid's low 15 bits. Each entry packs /// `{low byte: transparency mode, high byte: draw-enable}` for the BTL /// overlay tile with that id. -pub fn skip_overlay_id_table(reader: &mut BufReader) -> Result<()> { +/// +/// Semantics: a high byte of 0 hides the overlay entirely; a low byte of 0 +/// blits the tile diamond opaquely, any other value blits it skipping black +/// (0,0,0) pixels. +pub fn read_overlay_id_table(reader: &mut BufReader) -> Result> { let size = reader.read_i32::()?; - let skip: i64 = (size * 2).into(); - reader.seek(SeekFrom::Current(skip))?; - Ok(()) + let mut table = Vec::with_capacity(size as usize); + for _ in 0..size { + table.push(reader.read_u16::()?); + } + Ok(table) } // -------------------------------------------------------------------------- @@ -211,8 +217,8 @@ pub fn tiled_objects_block(reader: &mut BufReader) -> Result> 16) as u16 as i16; + // Overlay ref in bits 0–14. Bit 15 belongs to the shadow level, + // NOT the ref — mask it off instead of sign-dropping. + let btl_tile_id = (word & 0x7FFF) as i32; if btl_tile_id > 0 { - if shadow_and_flags != 0 { - println!( - "ReadRoofTiles: overlay tile {btl_tile_id} with shadow/flags {shadow_and_flags:#06x}" - ); - } - btl_tiles.insert(coords, i32::from(btl_tile_id)); + btl_tiles.insert(coords, btl_tile_id); } } } @@ -344,7 +345,7 @@ mod tests { // Decode let decoded_gtl = (value >> 10) & 0x7FFF; let decoded_blocked = (value & 0x1) == 1; - let decoded_object = ((value >> 1) & 0x1FF) as i32; + let decoded_object = (value >> 1) & 0x1FF; assert_eq!(decoded_gtl, 1234, "GTL tile id mismatch"); assert_eq!(decoded_object, 5, "Object id mismatch"); @@ -367,7 +368,7 @@ mod tests { let decoded_gtl = (value >> 10) & 0x7FFF; let decoded_blocked = (value & 0x1) == 1; - let decoded_object = ((value >> 1) & 0x1FF) as i32; + let decoded_object = (value >> 1) & 0x1FF; assert_eq!(decoded_gtl, 42); assert_eq!(decoded_object, 0); @@ -389,7 +390,7 @@ mod tests { let decoded_gtl = (value >> 10) & 0x7FFF; let decoded_blocked = (value & 0x1) == 1; - let decoded_object = ((value >> 1) & 0x1FF) as i32; + let decoded_object = (value >> 1) & 0x1FF; assert_eq!(decoded_gtl, 0x7FFF); assert_eq!(decoded_object, 0x1FF); diff --git a/src/map/render.rs b/src/map/render.rs index da9f2484..5fe7fec4 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -4,13 +4,13 @@ use std::fs::File; use std::io::{BufReader, Result, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use crate::map::tileset::{TILE_HEIGHT, Tile, plot_tile}; -use crate::sprite::{ImageInfo, SequenceInfo, rgb16_565_produce_color}; +use crate::map::tileset::{TILE_HEIGHT, Tile, plot_tile, plot_tile_opaque}; +use crate::sprite::{ImageInfo, rgb16_565_produce_color}; use byteorder::{LittleEndian, ReadBytesExt}; use super::types::{ - Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo, convert_map_coords_to_image_coords, - internal_sprite_sort_key, tiled_object_sort_key, + Coords, EventBlock, convert_map_coords_to_image_coords, internal_sprite_sort_key, + tiled_object_sort_key, }; use super::model::MapModel; @@ -28,12 +28,13 @@ pub struct LayerToggles { pub show_ground: bool, pub show_buildings: bool, pub show_roofs: bool, + /// Darken tiles according to their shadow/fog-of-war level from the + /// access-ref grid. + pub show_shadows: bool, pub show_internal_sprites: bool, pub show_monsters: bool, pub show_npcs: bool, pub show_objects: bool, - /// If true, render the entire map canvas instead of the occluded viewport. - pub full_map: bool, /// If true, output RGBA PNG where black (0,0,0) background pixels are /// transparent (alpha=0). When false, output standard RGB PNG. pub transparent: bool, @@ -49,11 +50,11 @@ impl Default for LayerToggles { show_ground: true, show_buildings: true, show_roofs: true, + show_shadows: true, show_internal_sprites: true, show_monsters: true, show_npcs: true, show_objects: true, - full_map: false, transparent: false, show_collisions: false, show_events: false, @@ -98,7 +99,6 @@ pub struct MapRenderConfig<'a> { pub reader: &'a mut BufReader, pub output_path: &'a Path, pub data: &'a super::MapData, - pub occlusion: bool, pub gtl_tileset: &'a [Tile], pub btl_tileset: &'a [Tile], pub map_id: &'a str, @@ -118,7 +118,6 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { reader, output_path, data, - occlusion, gtl_tileset, btl_tileset, map_id, @@ -126,16 +125,10 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { toggles, } = config; - let image_width = if occlusion { - data.model.occluded_map_in_pixels_width - } else { - data.model.map_width_in_pixels - }; - let image_height = if occlusion { - data.model.occluded_map_in_pixels_height - } else { - data.model.map_height_in_pixels - }; + // The renderer reproduces the observed occluded viewport — the region + // the player actually sees — not the full map canvas. + let image_width = data.model.occluded_map_in_pixels_width; + let image_height = data.model.occluded_map_in_pixels_height; println!("{:?}", data.model); println!( @@ -144,29 +137,12 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { image_height.unsigned_abs() ); - let offset_x = if !occlusion { - data.model.map_non_occluded_start_x - } else { - 0 - }; - let offset_y = if !occlusion { - data.model.map_non_occluded_start_y - } else { - 0 - }; - let mut imgbuf: ImageBuffer, Vec> = image::ImageBuffer::new(image_width.unsigned_abs(), image_height.unsigned_abs()); // ── Pass 1: Ground tiles ────────────────────────────────────────────── if toggles.show_ground { - plot_base( - &mut imgbuf, - &data.model, - occlusion, - &data.gtl_tiles, - gtl_tileset, - ); + plot_base(&mut imgbuf, &data.model, &data.gtl_tiles, gtl_tileset); } // ── Pre-load external entities (if game path given) ────────────────── @@ -256,8 +232,8 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { } let btl_tile_idx = btl_id.unsigned_abs() as usize; if let Some(tile) = btl_tileset.get(btl_tile_idx) { - let x = info.x + offset_x; - let y = info.y + (j as i32 * TILE_HEIGHT as i32) + offset_y; + let x = info.x; + let y = info.y + (j as i32 * TILE_HEIGHT as i32); plot_tile(&mut imgbuf, tile.colors, x, y); } } @@ -266,8 +242,8 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { let block = &data.sprite_blocks[*i]; let sequence = &data.internal_sprites[block.sprite_id]; let sprite = &sequence.frame_infos[0]; - let dest_x = block.sprite_x + offset_x; - let dest_y = block.sprite_y + offset_y; + let dest_x = block.sprite_x; + let dest_y = block.sprite_y; plot_sprite_on_bitmap(&mut imgbuf, reader, sprite, dest_x, dest_y)?; } ItemKind::Monster(i) => { @@ -276,9 +252,8 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { &mut imgbuf, &ext.monsters[*i], &mut sprite_cache, + &data.model, diagonal, - offset_x, - offset_y, ); } } @@ -288,9 +263,8 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { &mut imgbuf, &ext.npcs[*i], &mut sprite_cache, + &data.model, diagonal, - offset_x, - offset_y, ); } } @@ -300,9 +274,8 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { &mut imgbuf, &ext.extras[*i], &mut sprite_cache, + &data.model, diagonal, - offset_x, - offset_y, ); } } @@ -315,51 +288,44 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { plot_roofs( &mut imgbuf, &data.model, - occlusion, &data.btl_tiles, btl_tileset, + &data.overlay_modes, ); } + // ── Pass 3b: Shadows ───────────────────────────────────────────────── + // Reproduction of the observed lighting pass: on maps flagged + // Dark in AllMap.ini, level-0 tiles are blacked out and tiles with a + // light level are faded through the fogdata.dat tables. Drawn after + // world pixels so annotations added later stay fully visible. + if toggles.show_shadows + && let Some(fog) = prepare_shadow_pass(game_path, map_id) + { + plot_shadows(&mut imgbuf, &data.model, &data.access_ref_words, &fog); + } + // ── Pass 4: Overlays ───────────────────────────────────────────────── let diagonal = data.model.tiled_map_width + data.model.tiled_map_height; if toggles.show_collisions { - plot_collisions_overlay( - &mut imgbuf, - &data.model, - &data.collisions, - occlusion, - diagonal, - ); + plot_collisions_overlay(&mut imgbuf, &data.model, &data.collisions, diagonal); } if toggles.show_events { - plot_events_overlay(&mut imgbuf, &data.model, &data.events, occlusion, diagonal); + plot_events_overlay(&mut imgbuf, &data.model, &data.events, diagonal); } if toggles.show_draw_items && let Some(ref ext) = external { - plot_draw_items_overlay( - &mut imgbuf, - &ext.draw_items, - &data.model, - occlusion, - diagonal, - ); + plot_draw_items_overlay(&mut imgbuf, &ext.draw_items, &data.model, diagonal); } if toggles.show_npc_waypoints && let Some(ref ext) = external { - plot_npc_waypoints_overlay( - &mut imgbuf, - &ext.npc_records, - &data.model, - occlusion, - diagonal, - ); + plot_npc_waypoints_overlay(&mut imgbuf, &ext.npc_records, &data.model, diagonal); } // ── Save: RGBA PNG (transparent) or RGB PNG (solid black) ─────────── @@ -392,7 +358,6 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { pub fn plot_base( image: &mut ImageBuffer, Vec>, model: &MapModel, - occlusion: bool, gtl_tiles: &HashMap, gtl_tileset: &[Tile], ) { @@ -413,10 +378,8 @@ pub fn plot_base( }; let (mut sx, mut sy) = convert_map_coords_to_image_coords(x, y, map_diagonal_tiles); - if occlusion { - sx -= model.map_non_occluded_start_x; - sy -= model.map_non_occluded_start_y; - } + sx -= model.map_non_occluded_start_x; + sy -= model.map_non_occluded_start_y; plot_tile(image, gtl_tile.colors, sx, sy); } @@ -428,99 +391,6 @@ pub fn plot_base( // Object layer (sprites + tiled objects, sorted by ground-y) // -------------------------------------------------------------------------- -#[derive(Clone, Copy)] -pub struct PlotObjectsParams<'a> { - pub btl_tileset: &'a [Tile], - pub tiled_info: &'a [TiledObjectInfo], - pub internal_sprites: &'a [SequenceInfo], - pub sprite_blocks: &'a [SpriteInfoBlock], - pub offset_x: i32, - pub offset_y: i32, -} - -/// Legacy object renderer — renders only sprites + tiled objects sorted by Y. -/// Used by the database renderer; new code should use `render_map` with toggles. -pub fn plot_objects( - imgbuf: &mut ImageBuffer, Vec>, - reader: &mut BufReader, - _model: &MapModel, - _occlusion: bool, - _btl_tiles: &HashMap, - params: PlotObjectsParams, -) -> Result<()> { - enum Kind { - Sprite(usize), - TiledObject(usize), - } - struct Item { - ground_y: i32, - kind: Kind, - } - - let mut items = Vec::new(); - - for (i, block) in params.sprite_blocks.iter().enumerate() { - let sequence = ¶ms.internal_sprites[block.sprite_id]; - let sprite = &sequence.frame_infos[0]; - items.push(Item { - ground_y: block.sprite_y + sprite.height, - kind: Kind::Sprite(i), - }); - } - // Whole-stack bottom key (see tiled_object_sort_key) — consistent with - // render_map. - for (i, info) in params.tiled_info.iter().enumerate() { - items.push(Item { - ground_y: tiled_object_sort_key(info.y, info.ids.len()), - kind: Kind::TiledObject(i), - }); - } - items.sort_by_key(|it| it.ground_y); - - for item in items { - match item.kind { - Kind::Sprite(i) => plot_single_sprite( - imgbuf, - reader, - ¶ms.sprite_blocks[i], - params.internal_sprites, - params.offset_x, - params.offset_y, - )?, - Kind::TiledObject(i) => { - let tiled_info = ¶ms.tiled_info[i]; - for (j, &btl_id) in tiled_info.ids.iter().enumerate() { - if btl_id <= 0 { - continue; - } - let btl_tile_idx = btl_id.unsigned_abs() as usize; - if let Some(tile) = params.btl_tileset.get(btl_tile_idx) { - let x = tiled_info.x + params.offset_x; - let y = tiled_info.y + (j as i32 * TILE_HEIGHT as i32) + params.offset_y; - plot_tile(imgbuf, tile.colors, x, y); - } - } - } - } - } - Ok(()) -} - -fn plot_single_sprite( - imgbuf: &mut ImageBuffer, Vec>, - reader: &mut BufReader, - sprite_block: &SpriteInfoBlock, - internal_sprites: &[SequenceInfo], - offset_x: i32, - offset_y: i32, -) -> Result<()> { - let sequence = &internal_sprites[sprite_block.sprite_id]; - let sprite = &sequence.frame_infos[0]; - let dest_x = sprite_block.sprite_x + offset_x; - let dest_y = sprite_block.sprite_y + offset_y; - plot_sprite_on_bitmap(imgbuf, reader, sprite, dest_x, dest_y) -} - pub fn plot_sprite_on_bitmap( imgbuf: &mut ImageBuffer, Vec>, reader: &mut BufReader, @@ -558,9 +428,9 @@ pub fn plot_sprite_on_bitmap( pub fn plot_roofs( image: &mut ImageBuffer, Vec>, model: &MapModel, - occlusion: bool, btl_tiles: &HashMap, btl_tileset: &[Tile], + overlay_modes: &[u16], ) { let map_diagonal_tiles = model.tiled_map_width + model.tiled_map_height; let width = model.tiled_map_width; @@ -576,14 +446,189 @@ pub fn plot_roofs( if btl_tile_id > 0 { let btl_tile_idx = btl_tile_id as usize; if let Some(btl_tile) = btl_tileset.get(btl_tile_idx) { + // Per-id mode from the map's overlay table: high byte is + // the draw-enable flag (0 hides the overlay), low byte + // selects the blit mode — 0 draws opaquely, any other + // value skips black pixels. + let entry = overlay_modes.get(btl_tile_idx).copied().unwrap_or(0); + if entry >> 8 == 0 { + continue; + } let (mut sx, mut sy) = convert_map_coords_to_image_coords(x, y, map_diagonal_tiles); - if occlusion { - sx -= model.map_non_occluded_start_x; - sy -= model.map_non_occluded_start_y; + sx -= model.map_non_occluded_start_x; + sy -= model.map_non_occluded_start_y; + if entry & 0xFF == 0 { + plot_tile_opaque(image, btl_tile.colors, sx, sy); + } else { + plot_tile(image, btl_tile.colors, sx, sy); + } + } + } + } + } +} + +// -------------------------------------------------------------------------- +// Shadow overlay – reproduction of the observed lighting pass +// +// Applied near the end of the world render sequence. The pass only runs on +// maps flagged Dark in AllMap.ini — an observed per-map-catalog flag, +// toggleable at runtime via the "Turn off the light" / "Honey, It's too +// dark" cheat codes. +// +// Per tile, with level = (access_ref_word >> 15) & 0x7FFF: +// level ≥ 124 → untouched by the pass (124..=199 fit the level field +// but read past the fade tables = undefined; levels +// ≥ 200 are simply not darkened) +// level == 0 → span diamond filled solid black (fog of war) +// level 1..=123 → per pixel pair, red and green are scaled by f/32 where +// f = fogdata.dat[(level-1)*512 + pair]; blue is never +// modified. Flag bits 30–31 mark tiles whose factor is +// blended with dynamic entity lights via max(); statically +// we render the tile's own pattern. +// -------------------------------------------------------------------------- + +/// Per-row `(x_start, width)` spans of the shadow mask (32 rows). The rows +/// trace the 64×32 isometric tile diamond; each row is processed as +/// `width/2` pixel pairs sharing one fade factor. +pub const SHADOW_SPANS: [(i32, i32); 32] = [ + (30, 2), + (28, 6), + (26, 10), + (24, 14), + (22, 18), + (20, 22), + (18, 26), + (16, 30), + (14, 34), + (12, 38), + (10, 42), + (8, 46), + (6, 50), + (4, 54), + (2, 58), + (0, 62), + (0, 62), + (2, 58), + (4, 54), + (6, 50), + (8, 46), + (10, 42), + (12, 38), + (14, 34), + (16, 30), + (18, 26), + (20, 22), + (22, 18), + (24, 14), + (26, 10), + (28, 6), + (30, 2), +]; + +pub use super::fogdata::FogData; + +/// Loads the fade tables when the map `map_stem` is flagged Dark in +/// `/AllMap.ini`. Returns `None` for Light maps (the game skips +/// its lighting pass there) and reports IO problems on stderr. +pub fn load_fog_if_dark(game_path: &Path, map_stem: &str) -> Option { + use crate::references::all_map_ini::read_all_map_ini; + use crate::references::enums::MapLighting; + + let all_map_path = game_path.join("AllMap.ini"); + let maps = match read_all_map_ini(&all_map_path) { + Ok(maps) => maps, + Err(e) => { + eprintln!("Warning: could not read {all_map_path:?} ({e}); rendering without shadows"); + return None; + } + }; + + let is_dark = maps + .iter() + .any(|m| m.map_filename.eq_ignore_ascii_case(map_stem) && m.lighting == MapLighting::Dark); + if !is_dark { + return None; + } + + match FogData::load(game_path) { + Ok(fog) => Some(fog), + Err(e) => { + eprintln!("Warning: could not load fogdata.dat ({e}); rendering without shadows"); + None + } + } +} + +/// Loads the fade tables when the rendered map is flagged Dark in AllMap.ini. +/// +/// Returns `None` when shadows must not be drawn: no game path or a Light +/// map (see [`load_fog_if_dark`]). +fn prepare_shadow_pass(game_path: Option<&Path>, map_id: &str) -> Option { + let game_path = game_path?; + let base_name = map_id.split('.').next().unwrap_or(map_id); + load_fog_if_dark(game_path, base_name) +} + +/// Applies the observed per-tile lighting pass over the rendered world pixels. +/// +/// Levels `1..=[super::fogdata::ROWS]` index the fade tables; levels above +/// `ROWS` (including the accepted-but-undefined `124..=199` range, +/// which would read past the 123-row file) are skipped — the tile is left +/// untouched. Reading out of bounds there is undefined behavior; +/// tooling must not. Level 0 tiles are blacked out outright. +fn plot_shadows( + image: &mut ImageBuffer, Vec>, + model: &MapModel, + words: &HashMap, + fog: &FogData, +) { + let map_diagonal_tiles = model.tiled_map_width + model.tiled_map_height; + + for (&(x, y), &word) in words { + let level = (word >> 15) & 0x7FFF; + if level as usize > super::fogdata::ROWS { + continue; + } + + let (mut px, mut py) = convert_map_coords_to_image_coords(x, y, map_diagonal_tiles); + px -= model.map_non_occluded_start_x; + py -= model.map_non_occluded_start_y; + + let mut pair = 0usize; + for (row, &(start, width)) in SHADOW_SPANS.iter().enumerate() { + let py_row = py + row as i32; + if py_row < 0 { + continue; + } + for p in 0..(width as usize / 2) { + let x0 = px + start + (p * 2) as i32; + if x0 < 0 { + continue; + } + if level == 0 { + for dx in [0, 1] { + if let Some(pixel) = + image.get_pixel_mut_checked((x0 + dx) as u32, py_row as u32) + { + *pixel = Rgb([0, 0, 0]); + } + } + } else { + // Both pixels of the pair share one fade factor byte. + let f = u32::from(fog.factor(level, pair)); + for dx in [0, 1] { + if let Some(pixel) = + image.get_pixel_mut_checked((x0 + dx) as u32, py_row as u32) + { + pixel[0] = (u32::from(pixel[0]) * f / 32) as u8; + pixel[1] = (u32::from(pixel[1]) * f / 32) as u8; + // Blue stays untouched, as observed. + } } - plot_tile(image, btl_tile.colors, sx, sy); } + pair += 1; } } } @@ -719,18 +764,7 @@ pub fn collect_external_entities( let p = resolve("NpcInGame", &f); if let Ok(data) = NPC::read_file(&p) { for n in data { - let waypoints = [ - (n.goto1_filled, n.goto1_x, n.goto1_y), - (n.goto2_filled, n.goto2_x, n.goto2_y), - (n.goto3_filled, n.goto3_x, n.goto3_y), - (n.goto4_filled, n.goto4_x, n.goto4_y), - ]; - - let (x, y) = waypoints - .iter() - .find(|(filled, _, _)| i32::from(*filled) != 0) - .map(|(_, x, y)| (*x, *y)) - .unwrap_or((n.goto1_x, n.goto1_y)); + let (x, y) = npc_placement(&n); let dir = i32::from(n.waypoint1_facing_direction); let (seq, flip) = if dir > 4 { @@ -782,17 +816,18 @@ pub fn collect_external_entities( } } - // Load draw items for this map - let map_draw_id: i32 = map_base_name - .trim_start_matches(|c: char| !c.is_ascii_digit()) - .parse() - .unwrap_or(0); - let draw_items: Vec = - DrawItem::read_file(&game_path.join("Ref").join("DRAWITEM.ref")) + // Load draw items for this map. A draw item's map reference is the + // numeric ID from AllMap.ini — resolve it by filename (parsing digits + // from the stem gives wrong IDs: "cat1" → 1 instead of 3). + let map_draw_id = resolve_all_map_id(map_base_name, game_path); + let draw_items: Vec = match map_draw_id { + Some(id) => DrawItem::read_file(&game_path.join("Ref").join("DRAWITEM.ref")) .unwrap_or_default() .into_iter() - .filter(|d| d.map_id == map_draw_id) - .collect(); + .filter(|d| d.map_id == id) + .collect(), + None => Vec::new(), + }; Ok(ExternalEntities { monsters, @@ -803,18 +838,50 @@ pub fn collect_external_entities( }) } +/// Resolves a map file stem (e.g. `"cat1"`) to its AllMap.ini numeric ID. +/// +/// Mirrors the GUI's `resolve_map_id`; returns `None` when the map is not +/// listed in AllMap.ini. +fn resolve_all_map_id(map_base_name: &str, game_path: &Path) -> Option { + use crate::references::all_map_ini::read_all_map_ini; + read_all_map_ini(&game_path.join("AllMap.ini")) + .ok()? + .into_iter() + .find(|m| m.map_filename.eq_ignore_ascii_case(map_base_name)) + .map(|m| m.id) +} + +/// Where an NPC stands: its first active waypoint, falling back to waypoint 1. +/// +/// Shared by the renderer and the placement tests; the GUI's +/// `hit_test::npc_pos` implements the same rule. +pub fn npc_placement(n: &crate::references::npc_ref::NPC) -> (i32, i32) { + [ + (i32::from(n.goto1_filled), n.goto1_x, n.goto1_y), + (i32::from(n.goto2_filled), n.goto2_x, n.goto2_y), + (i32::from(n.goto3_filled), n.goto3_x, n.goto3_y), + (i32::from(n.goto4_filled), n.goto4_x, n.goto4_y), + ] + .iter() + .find(|(filled, _, _)| *filled != 0) + .map(|&(_, x, y)| (x, y)) + .unwrap_or((n.goto1_x, n.goto1_y)) +} + /// Render a single external entity sprite (or fallback marker) onto the image. fn render_entity_sprite( imgbuf: &mut ImageBuffer, Vec>, entity: &EntityRenderInfo, sprite_cache: &mut HashMap>>, + model: &MapModel, diagonal: i32, - offset_px_x: i32, - offset_px_y: i32, ) { + // Entities live in absolute canvas space; shift into the occluded + // viewport like every other layer by subtracting the map origin, + // matching the observed on-screen placement. let (px, py) = convert_map_coords_to_image_coords(entity.x, entity.y, diagonal); - let cx = px - offset_px_x + super::tileset::TILE_WIDTH as i32 / 2; - let cy = py - offset_px_y + TILE_HEIGHT as i32 / 2; + let cx = px - model.map_non_occluded_start_x + super::tileset::TILE_WIDTH as i32 / 2; + let cy = py - model.map_non_occluded_start_y + TILE_HEIGHT as i32 / 2; let mut rendered = false; if let Some(ref sp) = entity.sprite_path @@ -1082,7 +1149,6 @@ fn plot_collisions_overlay( imgbuf: &mut ImageBuffer, Vec>, model: &MapModel, collisions: &HashMap, - occlusion: bool, diagonal: i32, ) { let width = model.tiled_map_width; @@ -1099,10 +1165,8 @@ fn plot_collisions_overlay( continue; } let (mut px, mut py) = convert_map_coords_to_image_coords(x, y, diagonal); - if occlusion { - px -= model.map_non_occluded_start_x; - py -= model.map_non_occluded_start_y; - } + px -= model.map_non_occluded_start_x; + py -= model.map_non_occluded_start_y; // Diamond center let cx = px + super::tileset::TILE_WIDTH as i32 / 2; let cy = py + TILE_HEIGHT as i32 / 2; @@ -1120,7 +1184,6 @@ fn plot_events_overlay( imgbuf: &mut ImageBuffer, Vec>, model: &MapModel, events: &HashMap, - occlusion: bool, diagonal: i32, ) { let width = model.tiled_map_width; @@ -1140,10 +1203,8 @@ fn plot_events_overlay( continue; } let (mut px, mut py) = convert_map_coords_to_image_coords(x, y, diagonal); - if occlusion { - px -= model.map_non_occluded_start_x; - py -= model.map_non_occluded_start_y; - } + px -= model.map_non_occluded_start_x; + py -= model.map_non_occluded_start_y; let cx = px + super::tileset::TILE_WIDTH as i32 / 2; let cy = py + TILE_HEIGHT as i32 / 2; // Magenta dot @@ -1185,15 +1246,12 @@ fn plot_draw_items_overlay( imgbuf: &mut ImageBuffer, Vec>, draw_items: &[crate::references::draw_item::DrawItem], model: &MapModel, - occlusion: bool, diagonal: i32, ) { for di in draw_items { let (mut px, mut py) = convert_map_coords_to_image_coords(di.x_coord, di.y_coord, diagonal); - if occlusion { - px -= model.map_non_occluded_start_x; - py -= model.map_non_occluded_start_y; - } + px -= model.map_non_occluded_start_x; + py -= model.map_non_occluded_start_y; let cx = px + super::tileset::TILE_WIDTH as i32 / 2; let cy = py + TILE_HEIGHT as i32 / 2; let color = draw_item_color(di.item.item_type()); @@ -1220,7 +1278,6 @@ fn plot_npc_waypoints_overlay( imgbuf: &mut ImageBuffer, Vec>, npc_records: &[crate::references::npc_ref::NPC], model: &MapModel, - occlusion: bool, diagonal: i32, ) { let waypoint_colors: [[u8; 3]; 4] = [ @@ -1254,16 +1311,10 @@ fn plot_npc_waypoints_overlay( diagonal, ); - let mut sx = sx; - let mut sy = sy; - let mut ex = ex; - let mut ey = ey; - if occlusion { - sx -= model.map_non_occluded_start_x; - sy -= model.map_non_occluded_start_y; - ex -= model.map_non_occluded_start_x; - ey -= model.map_non_occluded_start_y; - } + let sx = sx - model.map_non_occluded_start_x; + let sy = sy - model.map_non_occluded_start_y; + let ex = ex - model.map_non_occluded_start_x; + let ey = ey - model.map_non_occluded_start_y; // Center on tile let sx = sx + super::tileset::TILE_WIDTH as i32 / 2; @@ -1484,3 +1535,234 @@ fn test_draw_number_clips_at_max_digits() { let has_white = img.pixels().any(|p| *p == Rgb([255, 255, 255])); assert!(has_white, "draw_number with max_digits should still render"); } + +#[test] +fn shadow_spans_trace_tile_diamond() { + assert_eq!(SHADOW_SPANS.len(), 32, "one span row per tile pixel row"); + let mut pairs = 0usize; + for (i, &(start, width)) in SHADOW_SPANS.iter().enumerate() { + assert_eq!(width % 2, 0, "rows are processed as pixel pairs"); + assert!( + start >= 0 && start + width <= 64, + "span must fit tile width" + ); + assert_eq!( + (start, width), + SHADOW_SPANS[31 - i], + "mask must be vertically symmetric" + ); + pairs += width as usize / 2; + } + assert_eq!( + pairs, 512, + "diamond covers exactly one fogdata row of pairs" + ); +} + +/// Builds a canvas filled with a known color and returns it together with +/// the image position of map tile (0, 0). +#[cfg(test)] +fn shadow_test_canvas() -> (ImageBuffer, Vec>, i32, i32) { + let model = test_model(); + let diagonal = model.tiled_map_width + model.tiled_map_height; + let (px, py) = convert_map_coords_to_image_coords(0, 0, diagonal); + let img = image::ImageBuffer::, Vec>::from_pixel(128, 128, Rgb([200, 100, 50])); + (img, px, py) +} + +#[cfg(test)] +fn test_model() -> MapModel { + MapModel { + tiled_map_width: 1, + tiled_map_height: 1, + ..MapModel::default() + } +} + +#[test] +fn level_zero_tile_is_blacked_out_inside_spans_only() { + let (mut img, px, py) = shadow_test_canvas(); + let fog = super::fogdata::FogData::from_raw(vec![31; super::fogdata::EXPECTED_LEN]); + let mut words = HashMap::new(); + words.insert((0, 0), 0u32); + plot_shadows(&mut img, &test_model(), &words, &fog); + + // Center of the diamond: solid black. + assert_eq!(img.get_pixel((px + 32) as u32, (py + 16) as u32)[0], 0); + // Corner outside the diamond: untouched. + assert_eq!(img.get_pixel(px as u32, py as u32)[0], 200); +} + +#[test] +fn lit_tiles_scale_red_green_pairwise_and_keep_blue() { + // Level 1 → fogdata row 0. Give each pair its own factor to prove the + // pair index walks the spans and both pixels share it. + let mut data = vec![0u8; super::fogdata::EXPECTED_LEN]; + for (pair, slot) in data.iter_mut().enumerate().take(512) { + *slot = (pair % 32) as u8; + } + let fog = super::fogdata::FogData::from_raw(data); + + let (mut img, px, py) = shadow_test_canvas(); + let mut words = HashMap::new(); + words.insert((0, 0), 1u32 << 15); + plot_shadows(&mut img, &test_model(), &words, &fog); + + // First span row: start=30, width=2 → one pair at x=px+30. + let f0 = u32::from(fog.factor(1, 0)); + let left = img.get_pixel((px + 30) as u32, py as u32); + assert_eq!(left[0], (200 * f0 / 32) as u8, "red scaled by f/32"); + assert_eq!(left[1], (100 * f0 / 32) as u8, "green scaled by f/32"); + assert_eq!(left[2], 50, "blue must stay untouched"); + + // A later pair (row 15's first pair) uses its own factor byte. + let &(start, _) = &SHADOW_SPANS[15]; + let pair_index: usize = SHADOW_SPANS[..15] + .iter() + .map(|&(_, w)| w as usize / 2) + .sum(); + let f = u32::from(fog.factor(1, pair_index)); + let p = img.get_pixel((px + start) as u32, (py + 15) as u32); + assert_eq!(p[0], (200 * f / 32) as u8); + assert_eq!(p[1], (100 * f / 32) as u8); + assert_eq!(p[2], 50); +} + +#[test] +fn levels_at_or_above_200_are_untouched() { + // 124..=199 fit the level field but have no data rows + // (the table covers 1..=123); they must be skipped, not panic. + for level in [124u32, 150, 199, 200, 250, 0x7FFF] { + let (mut img, _px, _py) = shadow_test_canvas(); + let fog = super::fogdata::FogData::from_raw(vec![0; super::fogdata::EXPECTED_LEN]); + let mut words = HashMap::new(); + words.insert((0, 0), level << 15); + plot_shadows(&mut img, &test_model(), &words, &fog); + assert!( + img.pixels().all(|p| *p == Rgb([200, 100, 50])), + "level {level} tiles must not be modified" + ); + } +} + +#[test] +fn fogdata_loads_from_fixture_game_dir() { + let fog = FogData::load(Path::new("fixtures/Dispel")).expect("fixture fogdata.dat"); + // Known values from the shipped file: level 1 is uniformly dark (f=2), + // level 65 is fully bright (f=31). + assert_eq!(fog.factor(1, 0), 2); + assert_eq!(fog.factor(65, 256), 31); +} + +#[test] +fn entity_marker_is_placed_in_viewport_space() { + // Regression: external entities must be shifted into the occluded + // viewport like every other layer — subtracting the map origin + // when placing NPCs/monsters/extras, as observed. + let model = MapModel { + tiled_map_width: 1, + tiled_map_height: 1, + map_non_occluded_start_x: 100, + map_non_occluded_start_y: 20, + ..MapModel::default() + }; + let diagonal = model.tiled_map_width + model.tiled_map_height; + + let mut img = ImageBuffer::from_pixel(512, 512, Rgb([0, 0, 0])); + let entity = EntityRenderInfo { + x: 3, + y: 5, + fallback_color: [255, 0, 0], + sprite_path: None, + sequence: 0, + flip: false, + }; + let mut cache = HashMap::new(); + render_entity_sprite(&mut img, &entity, &mut cache, &model, diagonal); + + let (px, py) = convert_map_coords_to_image_coords(3, 5, diagonal); + let cx = (px - model.map_non_occluded_start_x) + super::tileset::TILE_WIDTH as i32 / 2; + let cy = (py - model.map_non_occluded_start_y) + TILE_HEIGHT as i32 / 2; + assert_eq!( + img.get_pixel(cx as u32, cy as u32)[0], + 255, + "fallback marker must sit at the viewport-space tile center" + ); +} + +#[test] +fn draw_item_map_id_resolves_via_all_map_ini() { + // Regression: the draw-item map reference is the AllMap.ini numeric ID. + // Parsing digits from the file stem gave wrong IDs ("cat1" → 1 instead + // of 3), rendering another map's item drops. + let gp = std::path::Path::new("fixtures/Dispel"); + if !gp.exists() { + eprintln!("Skipping: fixtures not found"); + return; + } + assert_eq!(resolve_all_map_id("cat1", gp), Some(3)); + assert_eq!(resolve_all_map_id("dun01", gp), Some(7)); + assert_eq!(resolve_all_map_id("notamap", gp), None); +} + +#[test] +fn roof_overlay_modes_gate_and_pick_blit_style() { + let model = MapModel { + tiled_map_width: 1, + tiled_map_height: 1, + ..MapModel::default() + }; + let diagonal = model.tiled_map_width + model.tiled_map_height; + let (px, py) = convert_map_coords_to_image_coords(0, 0, diagonal); + let (sx, sy) = ( + px - model.map_non_occluded_start_x, + py - model.map_non_occluded_start_y, + ); + + // Tile id 1: half black, half red. Ids 2/3: fully red. + let mut colors = [crate::sprite::Color { r: 255, g: 0, b: 0 }; 1024]; + for c in colors.iter_mut().take(512) { + *c = crate::sprite::Color { r: 0, g: 0, b: 0 }; + } + let solid_red = [crate::sprite::Color { r: 255, g: 0, b: 0 }; 1024]; + let tileset = vec![ + Tile { colors: solid_red }, + Tile { colors }, + Tile { colors: solid_red }, + ]; + + let mut btl_tiles = HashMap::new(); + btl_tiles.insert((0, 0), 1i32); + + let mut img = image::ImageBuffer::from_pixel(128, 128, Rgb([10, 20, 30])); + + // Draw-enable high byte 0 → tile must not be drawn at all. + let modes = |hi: u16, lo: u16| vec![0u16, (hi << 8) | lo, 0, 0]; + plot_roofs(&mut img, &model, &btl_tiles, &tileset, &modes(0, 1)); + assert!( + img.get_pixel(sx as u32 + 32, sy as u32 + 16)[0] == 10, + "disabled overlay must be skipped" + ); + + // Transparent mode (low byte nonzero) → black pixels skipped, red drawn. + plot_roofs(&mut img, &model, &btl_tiles, &tileset, &modes(1, 1)); + assert_eq!( + img.get_pixel(sx as u32 + 32, sy as u32 + 16)[0], + 255, + "red pixel drawn" + ); + assert_eq!( + img.get_pixel(sx as u32 + 30, sy as u32)[0], + 10, + "black pixel (top rows) skipped in transparent mode" + ); + + // Opaque mode (low byte 0) → black pixels written. + let mut img2 = image::ImageBuffer::from_pixel(128, 128, Rgb([10, 20, 30])); + plot_roofs(&mut img2, &model, &btl_tiles, &tileset, &modes(1, 0)); + assert_eq!( + img2.get_pixel(sx as u32 + 30, sy as u32)[0], + 0, + "black pixel (top rows) written in opaque mode" + ); +} diff --git a/src/map/tileset.rs b/src/map/tileset.rs index 7efbe60f..8f69d5d8 100644 --- a/src/map/tileset.rs +++ b/src/map/tileset.rs @@ -226,6 +226,42 @@ pub fn plot_tile(imgbuf: &mut RgbImage, colors: [Color; 1024], dest_x: i32, dest } } +/// Same diamond-masked blit as [`plot_tile`], but writes black (0,0,0) +/// pixels too. The overlay id's mode entry picks per tile between this and +/// the black-skipping variant. +pub fn plot_tile_opaque(imgbuf: &mut RgbImage, colors: [Color; 1024], dest_x: i32, dest_y: i32) { + let img_w = imgbuf.width() as i32; + let img_h = imgbuf.height() as i32; + + if dest_x + TILE_WIDTH as i32 <= 0 + || dest_x >= img_w + || dest_y + TILE_HEIGHT as i32 <= 0 + || dest_y >= img_h + { + return; + } + + let mask = create_mask(); + let mut i = 0; + for (y, row) in mask.iter().enumerate() { + for x in 0..row[1] { + let pixel: Color = colors[i]; + i += 1; + + let final_x = dest_x + x + row[0]; + let final_y = dest_y + y as i32; + + if final_x >= 0 && final_x < img_w && final_y >= 0 && final_y < img_h { + imgbuf.put_pixel( + final_x as u32, + final_y as u32, + Rgb([pixel.r, pixel.g, pixel.b]), + ); + } + } + } +} + /// Generates a tileset atlas image containing all tiles in a grid /// /// # Arguments diff --git a/src/map/writer.rs b/src/map/writer.rs index 0c5647e2..cd808487 100644 --- a/src/map/writer.rs +++ b/src/map/writer.rs @@ -191,6 +191,7 @@ mod tests { }, gtl_tiles: HashMap::new(), btl_tiles: HashMap::new(), + overlay_modes: Vec::new(), access_ref_words: HashMap::new(), collisions: HashMap::new(), events: HashMap::new(), diff --git a/src/references/enums.rs b/src/references/enums.rs index 6c54b9d6..6965d652 100644 --- a/src/references/enums.rs +++ b/src/references/enums.rs @@ -196,9 +196,9 @@ impl From for PropertyFlag { #[repr(i32)] #[derive(Default)] pub enum MapLighting { - /// Dark map (interior, dungeon) + /// Light map (exterior, daytime) — no lighting pass applied Light = 0, - /// Light map (exterior, daytime) + /// Dark map (interior, dungeon) — the lighting pass darkens tiles #[default] Dark = 1, } From 07a6128aa975a43244096adfd351e093ea3e0abb Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:11:36 +0200 Subject: [PATCH 26/49] Remove map-from-db recipe and the Rust code --- Makefile | 35 +- docs/files/Map/MapModule.md | 12 - src/cli.rs | 28 -- src/commands/map.rs | 23 -- src/map/database.rs | 691 ------------------------------------ src/map/mod.rs | 7 +- src/map/render.rs | 92 ----- 7 files changed, 4 insertions(+), 884 deletions(-) delete mode 100644 src/map/database.rs diff --git a/Makefile b/Makefile index 691fb072..5703167d 100644 --- a/Makefile +++ b/Makefile @@ -22,13 +22,12 @@ extract-dialogue-text extract-weapons \ patch-dry-run patch-in-place patch validate validate-verbose \ list list-json list-filtered schema template template-pretty \ - map-help map-tiles map-atlas map-render map-render-full \ + map-help map-tiles map-atlas map-render \ map-render-transparent map-render-collisions map-render-events \ map-render-draw-items map-render-waypoints \ map-render-noground map-render-nobuildings map-render-noroofs \ map-render-nomonsters map-render-nonpcs map-render-noobjects \ map-extract-sprites map-to-json map-to-json-pretty map-to-db \ - map-from-db map-from-db-with-sprite \ map-atlas-btl map-atlas-gtl \ map-all-gtl-tiles map-all-btl-tiles zip-map-tiles \ database-import database-dialog-texts database-maps \ @@ -62,7 +61,7 @@ cargo_test: cargo test --workspace --all-features --quiet iced_test: - ICED_TEST_BACKEND=software cargo test -p dispel-gui --features "iced_test app::tests" + ICED_TEST_BACKEND=software cargo test -p dispel-gui --features "iced_test" # GUI simulations require Iced's CPU renderer when no GPU/window # server is available (for example, in CI or headless development shells). @@ -275,7 +274,6 @@ template-pretty: # map tiles – Extract every tile as a separate image # map atlas – Pack tiles into a single atlas PNG # map render – Render map with layers, overlays, and sprites -# map from-db – Render map from SQLite + atlas PNGs # map to-db – Import .MAP file into SQLite # map sprites – Extract map-internal sprites to PNGs # map to-json – Export map data as JSON @@ -295,7 +293,6 @@ map-atlas: # Optional flags: # --game-path Enable entity overlay (NPCs, monsters, extras) # --save-sprites Export sub-sprites from the map file -# --full-map Render full canvas (no occlusion viewport) # --transparent RGBA PNG with alpha channel # --collisions Show collision overlay # --events Show event overlay @@ -317,15 +314,6 @@ map-render: --output="map.png" \ --game-path="$(game_path)" -map-render-full: - cargo run -- map render \ - --map="$(game_path)/Map/$(map_id).map" \ - --btl="$(game_path)/Map/$(map_id).btl" \ - --gtl="$(game_path)/Map/$(map_id).gtl" \ - --output="map.png" \ - --game-path="$(game_path)" \ - --full-map - map-render-transparent: cargo run -- map render \ --map="$(game_path)/Map/$(map_id).map" \ @@ -452,25 +440,6 @@ map-to-json-pretty: map-to-db: cargo run -- map to-db --database "$(db_path)" --map "$(INPUT)" -# map from-db --database --map-id --gtl-atlas --btl-atlas --output -# [--game-path ] [--atlas-columns ] -map-from-db: - cargo run -- map from-db \ - --database "$(db_path)" \ - --map-id "$(map_id)" \ - --gtl-atlas "$(map_id).gtl.png" \ - --btl-atlas "$(map_id).btl.png" \ - -o "$(out)_$(map_id).png" - -map-from-db-with-sprite: - cargo run -- map from-db \ - --database "$(db_path)" \ - --map-id "$(map_id)" \ - --gtl-atlas "$(map_id).gtl.png" \ - --btl-atlas "$(map_id).btl.png" \ - -o "$(out)_$(map_id).png" \ - --game-path "$(game_path)" - # map atlas convenience (shortcut: make atlas from game path + map_id) map-atlas-btl: cargo run -- map atlas \ diff --git a/docs/files/Map/MapModule.md b/docs/files/Map/MapModule.md index 661508a3..3317daaa 100644 --- a/docs/files/Map/MapModule.md +++ b/docs/files/Map/MapModule.md @@ -341,7 +341,6 @@ struct LoadedSpriteFrame { |--------------------------------------------------------------------------------------|-------------------------------------| | `extract(map, btl, gtl, output, save_sprites)` | Render `.MAP` + tilesets to PNG | | `extract_sprites(map, output_dir)` | Extract embedded sprites to PNGs | -| `render_from_database(db, map_id, gtl_atlas, btl_atlas, columns, output, game_path)` | Render map from SQLite + atlas PNGs | ### Database @@ -359,7 +358,6 @@ struct LoadedSpriteFrame { | Function | Description | |------------------------------------------------------|-------------------------------------------------| | `convert_map_coords_to_image_coords(x, y, diagonal)` | Convert tile coords to pixel coords | -| `plot_atlas_tile(params)` | Blit tile from atlas with alpha blending | | `plot_entity_sprite(dest, sprite, x, y, flip)` | Plot sprite frame with optional horizontal flip | --- @@ -374,16 +372,6 @@ struct LoadedSpriteFrame { 3. plot_roofs() — Roof/building tiles (BTL) ``` -### Pass Order (from `database.rs` — render_from_database) - -``` -1. Ground tiles — GTL tiles from atlas PNG -2. Objects — Stacked BTL tiles from atlas, sorted by ground_y -3. Roofs — BTL roof tiles from atlas -4. External entities — NPCs (green), monsters (red), extras (blue) - — Real sprites if game_path provided, else colored markers -``` - ### Depth Sorting Sprites and tiled objects are sorted by `ground_y` before rendering: diff --git a/src/cli.rs b/src/cli.rs index 7d2ed6b3..1eb089c6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -269,34 +269,6 @@ pub enum MapCommands { #[arg(long)] npc_waypoints: bool, }, - /// Render a map from SQLite database - #[command( - about = "Render map from database", - long_about = "Renders a map image using tile data from SQLite database and atlas PNG files." - )] - FromDb { - /// Path to the SQLite database file - #[arg(short, long, default_value = "database.sqlite")] - database: String, - /// Map ID to render (e.g., "cat1") - #[arg(short, long)] - map_id: String, - /// Path to the ground tileset atlas PNG - #[arg(long)] - gtl_atlas: String, - /// Path to the building/roof tileset atlas PNG - #[arg(long)] - btl_atlas: String, - /// Number of tiles per row in the atlas (default: 48) - #[arg(long, default_value = "48")] - atlas_columns: u32, - /// Path to save the output PNG - #[arg(short, long)] - output: String, - /// Path to the Dispel game directory (enables sprite rendering for NPCs, monsters, extras) - #[arg(long)] - game_path: Option, - }, /// Import a map file into SQLite database #[command( about = "Import map to database", diff --git a/src/commands/map.rs b/src/commands/map.rs index 3215e1f6..fe454174 100644 --- a/src/commands/map.rs +++ b/src/commands/map.rs @@ -1,7 +1,6 @@ use super::Command; use crate::cli::MapCommands; use dispel_core::map; -use dispel_core::map::database::RenderConfig; use std::error::Error; use std::fs; use std::path::Path; @@ -81,28 +80,6 @@ impl Command for MapCommand { .map_err(|e| format!("ERROR: could not render map: {e}"))?; Ok(()) } - MapCommands::FromDb { - database, - map_id, - gtl_atlas, - btl_atlas, - atlas_columns, - output, - game_path, - } => { - eprintln!("Rendering map from database..."); - map::render_from_database(RenderConfig { - database_path: Path::new(database), - map_id, - gtl_atlas_path: Path::new(gtl_atlas), - btl_atlas_path: Path::new(btl_atlas), - atlas_columns: *atlas_columns, - output_path: Path::new(output), - game_path: game_path.as_deref().map(Path::new), - }) - .map_err(|e| format!("ERROR: could not render map from database: {e}"))?; - Ok(()) - } MapCommands::ToDb { database, map } => { eprintln!("Importing map to database..."); map::import_to_database(Path::new(database), Path::new(map)) diff --git a/src/map/database.rs b/src/map/database.rs deleted file mode 100644 index 47f8bdda..00000000 --- a/src/map/database.rs +++ /dev/null @@ -1,691 +0,0 @@ -use std::collections::HashMap; -use std::io::Result; -use std::path::Path; - -use crate::map::tileset; - -use super::render::{AtlasTileParams, plot_atlas_tile}; -use super::sprite_loader::{LoadedSpriteFrame, load_sprite_frames, plot_entity_sprite}; -use super::types::{ - TILE_HEIGHT_HALF, TILE_HORIZONTAL_OFFSET_HALF, TILE_WIDTH_HALF, TiledObjectInfo, - convert_map_coords_to_image_coords, -}; - -/// Configuration for rendering a map from database -pub struct RenderConfig<'a> { - pub database_path: &'a Path, - pub map_id: &'a str, - pub gtl_atlas_path: &'a Path, - pub btl_atlas_path: &'a Path, - pub atlas_columns: u32, - pub output_path: &'a Path, - pub game_path: Option<&'a Path>, -} - -/// Renders a map image from data stored in the SQLite database together with -/// pre-built tileset atlas PNG files. -/// -/// # Arguments -/// * `config` - Render configuration -pub fn render_from_database(config: RenderConfig) -> Result<()> { - let RenderConfig { - database_path, - map_id, - gtl_atlas_path, - btl_atlas_path, - atlas_columns, - output_path, - game_path, - } = config; - use image::RgbaImage; - use rusqlite::Connection; - - println!("Loading atlases..."); - let gtl_atlas = - image::open(gtl_atlas_path).map_err(|e| std::io::Error::other(e.to_string()))?; - let btl_atlas = - image::open(btl_atlas_path).map_err(|e| std::io::Error::other(e.to_string()))?; - - println!("Opening database..."); - let conn = Connection::open(database_path).map_err(|e| std::io::Error::other(e.to_string()))?; - - // ── Map tile bounds ──────────────────────────────────────────────────── - let bounds: (Option, Option, Option, Option) = conn - .query_row( - "SELECT MIN(x), MAX(x), MIN(y), MAX(y) FROM map_tiles WHERE map_id = ?", - [map_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - let (min_x, max_x, min_y, max_y) = match bounds { - (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), - _ => { - return Err(std::io::Error::other(format!( - "Map '{}' not found in database or has no tiles", - map_id - ))); - } - }; - - let map_width = max_x - min_x + 1; - let map_height = max_y - min_y + 1; - - println!( - "Map bounds: x=[{}, {}], y=[{}, {}], size={}x{}", - min_x, max_x, min_y, max_y, map_width, map_height - ); - - // ── Tile data ────────────────────────────────────────────────────────── - println!("Fetching tiles and objects..."); - let mut gtl_tiles = HashMap::new(); - let mut btl_tiles = HashMap::new(); - - { - let mut stmt = conn - .prepare("SELECT x, y, gtl_tile_id, btl_tile_id FROM map_tiles WHERE map_id = ?") - .map_err(|e| std::io::Error::other(e.to_string()))?; - let iter = stmt - .query_map([map_id], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - row.get::<_, i32>(3)?, - )) - }) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - for row in iter { - let (x, y, gtl, btl) = row.map_err(|e| std::io::Error::other(e.to_string()))?; - if gtl > 0 { - gtl_tiles.insert((x, y), gtl); - } - if btl > 0 { - btl_tiles.insert((x, y), btl); - } - } - } - - // ── Tiled objects ────────────────────────────────────────────────────── - // Preserve object_index as tiebreaker: equal PositionOrder (y + size*TILE_HEIGHT) - // must resolve in object_index order, matching C# IInterlacedOrderObjectComparer. - let mut objects_map: HashMap = HashMap::new(); - { - let mut stmt = conn - .prepare("SELECT x, y, btl_tile_id, object_index FROM map_objects WHERE map_id = ? ORDER BY object_index, stack_order") - .map_err(|e| std::io::Error::other(e.to_string()))?; - let iter = stmt - .query_map([map_id], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - row.get::<_, i32>(3)?, - )) - }) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - for row in iter { - let (x, y, tile_id, idx) = row.map_err(|e| std::io::Error::other(e.to_string()))?; - let entry = objects_map.entry(idx).or_insert(( - idx, - TiledObjectInfo { - ids: Vec::new(), - x, - y, - }, - )); - entry.1.ids.push(tile_id as i16); - } - } - let mut objects: Vec<(i32, TiledObjectInfo)> = objects_map.into_values().collect(); - - // ── Map metadata (original dimensions + offsets) ─────────────────────── - let metadata: (Option, Option, Option, Option) = conn - .query_row( - "SELECT tiled_width, tiled_height, non_occluded_x, non_occluded_y FROM map_metadata WHERE map_id = ?", - [map_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) - .unwrap_or((None, None, None, None)); - - let (width, height, non_occluded_x, non_occluded_y) = match metadata { - (Some(w), Some(h), nox, noy) if w > 0 && h > 0 => { - (w, h, nox.unwrap_or(0), noy.unwrap_or(0)) - } - _ => { - println!("WARNING: Map dimensions not found in map_metadata, falling back to bounds"); - (map_width, map_height, 0, 0) - } - }; - - let diagonal = width + height; - let offset_x_tiles = width / 2; - let offset_y_tiles = height / 2; - - let image_width = (diagonal * TILE_HORIZONTAL_OFFSET_HALF) as u32; - let image_height = (diagonal * TILE_HEIGHT_HALF) as u32; - - println!("Creating image: {}x{} pixels", image_width, image_height); - let mut imgbuf: RgbaImage = image::ImageBuffer::new(image_width, image_height); - - // ── Pass 1: Ground tiles ─────────────────────────────────────────────── - println!("Rendering pass 1: Ground..."); - for ((real_x, real_y), gtl_id) in >l_tiles { - let x = real_x + offset_x_tiles; - let y = real_y + offset_y_tiles; - let (dest_x, dest_y) = convert_map_coords_to_image_coords(x, y, diagonal); - let atlas_x = (*gtl_id as u32 % atlas_columns) * tileset::TILE_WIDTH; - let atlas_y = (*gtl_id as u32 / atlas_columns) * tileset::TILE_HEIGHT; - plot_atlas_tile(AtlasTileParams { - dest: &mut imgbuf, - atlas: >l_atlas, - src_x: atlas_x, - src_y: atlas_y, - tile_w: tileset::TILE_WIDTH, - tile_h: tileset::TILE_HEIGHT, - dest_x, - dest_y, - }); - } - - // ── Pass 2: Objects ──────────────────────────────────────────────────── - println!("Rendering pass 2: Objects..."); - objects.sort_by_key(|(idx, o)| { - ( - o.y + (o.ids.len() as i32 * tileset::TILE_HEIGHT as i32), - *idx, - ) - }); - for (_, obj) in &objects { - for (i, &btl_id) in obj.ids.iter().enumerate() { - if btl_id <= 0 { - continue; - } - let atlas_x = (btl_id as u32 % atlas_columns) * tileset::TILE_WIDTH; - let atlas_y = (btl_id as u32 / atlas_columns) * tileset::TILE_HEIGHT; - let x = obj.x + non_occluded_x; - let y = obj.y + (i as i32 * tileset::TILE_HEIGHT as i32) + non_occluded_y; - plot_atlas_tile(AtlasTileParams { - dest: &mut imgbuf, - atlas: &btl_atlas, - src_x: atlas_x, - src_y: atlas_y, - tile_w: tileset::TILE_WIDTH, - tile_h: tileset::TILE_HEIGHT, - dest_x: x, - dest_y: y, - }); - } - } - - // ── Pass 3: Roofs ────────────────────────────────────────────────────── - println!("Rendering pass 3: Roofs..."); - for ((real_x, real_y), btl_id) in &btl_tiles { - let x = real_x + offset_x_tiles; - let y = real_y + offset_y_tiles; - let (dest_x, dest_y) = convert_map_coords_to_image_coords(x, y, diagonal); - let atlas_x = (*btl_id as u32 % atlas_columns) * tileset::TILE_WIDTH; - let atlas_y = (*btl_id as u32 / atlas_columns) * tileset::TILE_HEIGHT; - plot_atlas_tile(AtlasTileParams { - dest: &mut imgbuf, - atlas: &btl_atlas, - src_x: atlas_x, - src_y: atlas_y, - tile_w: tileset::TILE_WIDTH, - tile_h: tileset::TILE_HEIGHT, - dest_x, - dest_y, - }); - } - - // ── Pass 3.5: Internal sprites from database ─────────────────────── - println!("Rendering pass 3.5: Internal sprites (from DB)..."); - { - let mut stmt = conn - .prepare( - "SELECT sf.png_blob, sf.width, sf.height, sf.origin_x, sf.origin_y, - s.x AS placement_x, s.y AS placement_y - FROM map_sprite_frames sf - JOIN map_sprites s ON s.map_id = sf.map_id - AND s.internal_sprite_id = sf.internal_sprite_id - WHERE sf.map_id = ?1 AND sf.frame_index = 0", - ) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - let iter = stmt - .query_map([map_id], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - row.get::<_, i32>(3)?, - row.get::<_, i32>(4)?, - row.get::<_, i32>(5)?, - row.get::<_, i32>(6)?, - )) - }) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - for row in iter { - let (png_blob, _width, _height, origin_x, origin_y, sx, sy) = - row.map_err(|e| std::io::Error::other(e.to_string()))?; - - // Decode PNG from DB blob - let img = image::load_from_memory(&png_blob) - .map_err(|e| std::io::Error::other(e.to_string()))? - .into_rgba8(); - - // Convert placement tile coords to image pixel coords - let x = sx + offset_x_tiles; - let y = sy + offset_y_tiles; - let (dest_x, dest_y) = convert_map_coords_to_image_coords(x, y, diagonal); - - // Render using existing sprite_loader function - plot_entity_sprite( - &mut imgbuf, - &img, - dest_x - origin_x, - dest_y - origin_y, - false, - ); - } - } - - // ── Pass 4: External entities (NPCs, monsters, extras) ───────────────── - println!("Rendering pass 4: External entities..."); - render_external_entities( - &conn, - &mut imgbuf, - map_id, - game_path, - diagonal, - offset_x_tiles, - offset_y_tiles, - ); - - println!("Saving to {:?}...", output_path); - imgbuf - .save(output_path) - .map_err(|e| std::io::Error::other(e.to_string()))?; - - Ok(()) -} - -// -------------------------------------------------------------------------- -// External entity (NPC / monster / extra) rendering helpers -// -------------------------------------------------------------------------- - -struct ExternalEntity { - x: i32, - y: i32, - color: image::Rgba, - sprite_filename: Option, - sprite_dir: &'static str, - sprite_sequence: usize, - flip: bool, -} - -fn render_external_entities( - conn: &rusqlite::Connection, - imgbuf: &mut image::RgbaImage, - map_id: &str, - game_path: Option<&Path>, - diagonal: i32, - _offset_x_tiles: i32, - _offset_y_tiles: i32, -) { - let refs_query = " - SELECT i.monsters_filename, i.npc_filename, i.extra_filename - FROM map_inis i - JOIN maps m ON m.id = i.map_id - WHERE m.map_filename = ? COLLATE NOCASE - LIMIT 1 - "; - - let refs = conn - .query_row(refs_query, [map_id], |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - )) - }) - .ok(); - - let mut entities: Vec = Vec::new(); - - if let Some((monsters_file, npc_file, extra_file)) = refs { - collect_monsters(conn, monsters_file, &mut entities); - collect_npcs(conn, npc_file, &mut entities); - collect_extras(conn, extra_file, &mut entities); - } - - println!(" Found {} external entities to render", entities.len()); - - let mut sprite_cache: HashMap>> = - HashMap::new(); - - for entity in &entities { - // Entity tile coordinates are raw game tile coords (same system as the - // .ref files store them). Unlike DB tile coords they are NOT shifted by - // offset_x_tiles, so pass directly to convert_map_coords_to_image_coords. - let (dest_x, dest_y) = convert_map_coords_to_image_coords(entity.x, entity.y, diagonal); - let cx = dest_x + TILE_WIDTH_HALF; - let cy = dest_y + TILE_HEIGHT_HALF; - - let mut rendered = false; - - if let Some(ref sf) = entity.sprite_filename { - let key = format!("{}/{}", entity.sprite_dir, sf); - - if !sprite_cache.contains_key(&key) { - let loaded = load_entity_sprite(conn, &key, entity.sprite_dir, sf, game_path); - sprite_cache.insert(key.clone(), loaded); - } - - if let Some(Some(frames)) = sprite_cache.get(&key) - && !frames.is_empty() - { - let idx = entity.sprite_sequence.min(frames.len() - 1); - let frame = &frames[idx]; - let sx = if entity.flip { - cx - (frame.image.width() as i32 - frame.origin_x) - } else { - cx - frame.origin_x - }; - let sy = cy - frame.origin_y; - plot_entity_sprite(imgbuf, &frame.image, sx, sy, entity.flip); - rendered = true; - } - } - - if !rendered { - draw_entity_marker(imgbuf, cx, cy, entity.color); - } - } -} - -/// Tries to load entity sprite frames. First attempts filesystem (if `game_path` -/// is provided), then falls back to the `sprite_frames` database table which -/// stores pre-decoded frame PNG blobs. -fn load_entity_sprite( - conn: &rusqlite::Connection, - key: &str, - sprite_dir: &str, - sf: &str, - game_path: Option<&Path>, -) -> Option> { - // 1. Try filesystem if game_path is available - if let Some(gp) = game_path { - let try_paths = vec![ - gp.join(sprite_dir).join(sf), - gp.join(sprite_dir).join(sf.to_ascii_uppercase()), - gp.join(sprite_dir).join(sf.to_ascii_lowercase()), - ]; - - for p in &try_paths { - if p.exists() - && let Some(frames) = load_sprite_frames(p) - { - return Some(frames); - } - } - } - - // 2. Fallback: try database sprite_frames table (first frame of each sequence) - load_sprite_frames_from_db(conn, key) - .or_else(|| load_sprite_frames_from_db(conn, &key.to_ascii_lowercase())) - .or_else(|| load_sprite_frames_from_db(conn, &key.to_ascii_uppercase())) -} - -/// Queries `sprite_frames` for the first frame of each sequence for a given path, -/// decoding the PNG blobs into `LoadedSpriteFrame`. -fn load_sprite_frames_from_db( - conn: &rusqlite::Connection, - normalized_path: &str, -) -> Option> { - let sql = "\ - SELECT sf.png_blob, sf.width, sf.height, sf.origin_x, sf.origin_y \ - FROM sprite_frames sf \ - JOIN sprite_files s ON s.id = sf.sprite_file_id \ - WHERE s.normalized_path = ?1 AND sf.frame_index = 0 \ - ORDER BY sf.sequence_index"; - let mut stmt = conn.prepare(sql).ok()?; - let iter = stmt - .query_map([normalized_path], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - row.get::<_, i32>(3)?, - row.get::<_, i32>(4)?, - )) - }) - .ok()?; - - let mut frames: Vec = Vec::new(); - for row in iter.flatten() { - let (png_blob, _width, _height, origin_x, origin_y) = row; - let img = match image::load_from_memory(&png_blob) { - Ok(i) => i.into_rgba8(), - Err(_) => continue, - }; - frames.push(LoadedSpriteFrame { - image: img, - origin_x, - origin_y, - }); - } - - if frames.is_empty() { - None - } else { - Some(frames) - } -} - -fn collect_monsters( - conn: &rusqlite::Connection, - file: Option, - entities: &mut Vec, -) { - if let Some(mut f) = file { - f = f.replace('\\', "/"); - if let Some(name) = f.split('/').next_back() { - let q = format!( - "SELECT mr.map_x, mr.map_y, mi.sprite_filename \ - FROM monster_refs mr \ - LEFT JOIN monster_inis mi ON mi.id = mr.monster_db_id \ - WHERE mr.file_path LIKE '%{}'", - name - ); - if let Ok(mut stmt) = conn.prepare(&q) - && let Ok(iter) = stmt.query_map([], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, Option>(2)?, - )) - }) - { - for row in iter.filter_map(|r| r.ok()) { - entities.push(ExternalEntity { - x: row.0, - y: row.1, - color: image::Rgba([255, 60, 60, 255]), - sprite_filename: row.2, - sprite_dir: "MonsterInGame", - sprite_sequence: 3, - flip: false, - }); - } - } - } - } -} - -fn collect_npcs( - conn: &rusqlite::Connection, - file: Option, - entities: &mut Vec, -) { - if let Some(mut f) = file { - f = f.replace('\\', "/"); - if let Some(name) = f.split('/').next_back() { - let q = format!( - "SELECT nr.show_on_event, nr.goto1_filled, nr.goto1_x, nr.goto1_y, - nr.goto2_filled, nr.goto2_x, nr.goto2_y, - nr.goto3_filled, nr.goto3_x, nr.goto3_y, - nr.goto4_filled, nr.goto4_x, nr.goto4_y, - ni.sprite_filename, nr.waypoint1_facing_direction \ - FROM npc_refs nr \ - LEFT JOIN npc_inis ni ON ni.id = nr.npc_ini_id \ - WHERE nr.file_path LIKE '%{}'", - name - ); - if let Ok(mut stmt) = conn.prepare(&q) - && let Ok(iter) = stmt.query_map([], |row| { - Ok(( - row.get::<_, i32>(0)?, // show_on_event - row.get::<_, i32>(1)?, // goto1_filled - row.get::<_, i32>(2)?, // goto1_x - row.get::<_, i32>(3)?, // goto1_y - row.get::<_, i32>(4)?, // goto2_filled - row.get::<_, i32>(5)?, // goto2_x - row.get::<_, i32>(6)?, // goto2_y - row.get::<_, i32>(7)?, // goto3_filled - row.get::<_, i32>(8)?, // goto3_x - row.get::<_, i32>(9)?, // goto3_y - row.get::<_, i32>(10)?, // goto4_filled - row.get::<_, i32>(11)?, // goto4_x - row.get::<_, i32>(12)?, // goto4_y - row.get::<_, Option>(13)?, // sprite_filename - row.get::<_, i32>(14)?, // waypoint1_facing_direction - )) - }) - { - for row in iter.filter_map(|r| r.ok()) { - // Find first active waypoint - let waypoints = [ - (row.1, row.2, row.3), // goto1 - (row.4, row.5, row.6), // goto2 - (row.7, row.8, row.9), // goto3 - (row.10, row.11, row.12), // goto4 - ]; - - let (x, y) = waypoints - .iter() - .find(|(filled, _, _)| *filled != 0) - .map(|(_, x, y)| (*x, *y)) - .unwrap_or((row.2, row.3)); // Fallback to goto1 - - let direction = row.14; - let seq = if direction > 4 { - (8 - direction) as usize - } else { - direction as usize - }; - entities.push(ExternalEntity { - x, - y, - color: image::Rgba([60, 255, 60, 255]), - sprite_filename: row.13, - sprite_dir: "NpcInGame", - sprite_sequence: seq, - flip: direction > 4, - }); - } - } - } - } -} - -fn collect_extras( - conn: &rusqlite::Connection, - file: Option, - entities: &mut Vec, -) { - if let Some(mut f) = file { - f = f.replace('\\', "/"); - if let Some(name) = f.split('/').next_back() { - let q = format!( - "SELECT er.map_x, er.map_y, e.sprite_filename, er.direction, er.object_type, er.requires_key \ - FROM extra_refs er \ - LEFT JOIN extras e ON e.id = er.extra_definition_id \ - WHERE er.file_path LIKE '%{}'", - name - ); - if let Ok(mut stmt) = conn.prepare(&q) - && let Ok(iter) = stmt.query_map([], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, i32>(3)?, - row.get::<_, i32>(4)?, - row.get::<_, i32>(5)?, - )) - }) - { - for row in iter.filter_map(|r| r.ok()) { - let (rotation, obj_type, closed) = (row.3, row.4, row.5); - let seq = if obj_type == 0 { - (2 * closed + rotation) as usize - } else { - rotation as usize - }; - entities.push(ExternalEntity { - x: row.0, - y: row.1, - color: image::Rgba([80, 120, 255, 255]), - sprite_filename: row.2, - sprite_dir: "ExtraInGame", - sprite_sequence: seq, - flip: false, - }); - } - } - } - } -} - -/// Draws a diamond-shaped marker with a 1px dark outline, centred at (cx, cy). -fn draw_entity_marker(dest: &mut image::RgbaImage, cx: i32, cy: i32, fill: image::Rgba) { - let r: i32 = 5; - let outline = image::Rgba([0u8, 0, 0, 255]); - let w = dest.width() as i32; - let h = dest.height() as i32; - - let put = |dest: &mut image::RgbaImage, px: i32, py: i32, color: image::Rgba| { - if px >= 0 && px < w && py >= 0 && py < h { - dest.put_pixel(px as u32, py as u32, color); - } - }; - - for d in 0..r { - for &(px, py) in &[ - (cx + d, cy + (r - d)), - (cx + d, cy - (r - d)), - (cx - d, cy + (r - d)), - (cx - d, cy - (r - d)), - (cx + (r - d), cy + d), - (cx + (r - d), cy - d), - (cx - (r - d), cy + d), - (cx - (r - d), cy - d), - ] { - put(dest, px, py, outline); - } - } - for &(px, py) in &[(cx, cy + r), (cx, cy - r), (cx + r, cy), (cx - r, cy)] { - put(dest, px, py, outline); - } - for dx in -(r - 1)..=r - 1 { - for dy in -(r - 1)..=r - 1 { - if dx.abs() + dy.abs() < r { - put(dest, cx + dx, cy + dy, fill); - } - } - } -} diff --git a/src/map/mod.rs b/src/map/mod.rs index 8f5e1d43..4ed5efa6 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -7,9 +7,8 @@ // model.rs – MapModel struct and geometry parser (read_map_model) // reader.rs – Binary block readers for the native .map file format // render.rs – Isometric rendering pipeline (ground / objects / roofs, -// sprite on bitmap, atlas tile blitter) +// sprite on bitmap) // sprite_loader.rs– LoadedSpriteFrame, load_sprite_frames, plot_entity_sprite -// database.rs – render_from_database + entity overlay helpers // tileset.rs – Tileset extraction, tile plotting, and atlas generation // =========================================================================== @@ -99,7 +98,6 @@ // FILE SIZE CALCULATION: // Total size = header + blocks + width×height×12 (three packed-u32 grids) // -pub mod database; pub mod fogdata; pub mod model; pub mod reader; @@ -111,7 +109,6 @@ pub mod types; pub mod writer; // ── Re-export the entire public surface so external code needs no changes ── -pub use database::render_from_database; pub use model::{MapModel, read_map_model}; pub use render::{EntityRenderInfo, ExternalEntities, LayerToggles}; pub use types::{ @@ -566,7 +563,7 @@ pub fn extract( /// - Pixel data for each animation frame /// /// Sprites are extracted with their original animation sequences preserved, -/// allowing for proper reconstruction of in-game animations. +/// allowing for proper reconstruction of the observed animations. pub fn extract_sprites(input_map_file: &Path, output_path: &Path) -> IoResult<()> { let file = File::open(input_map_file)?; let mut reader = BufReader::new(file); diff --git a/src/map/render.rs b/src/map/render.rs index 5fe7fec4..2184a2f8 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -1359,74 +1359,6 @@ fn plot_npc_waypoints_overlay( } } -// -------------------------------------------------------------------------- -// Atlas tile blitter (used by render_from_database) -// -------------------------------------------------------------------------- - -pub struct AtlasTileParams<'a> { - pub dest: &'a mut image::RgbaImage, - pub atlas: &'a image::DynamicImage, - pub src_x: u32, - pub src_y: u32, - pub tile_w: u32, - pub tile_h: u32, - pub dest_x: i32, - pub dest_y: i32, -} - -/// Copies a tile from a pre-built atlas image onto the destination buffer, -/// with per-pixel alpha blending support. -pub fn plot_atlas_tile(params: AtlasTileParams) { - use image::GenericImageView; - - let dest_x = if params.dest_x < 0 || params.dest_y < 0 { - return; - } else { - params.dest_x as u32 - }; - let dest_y = params.dest_y as u32; - - if dest_x + params.tile_w > params.dest.width() - || dest_y + params.tile_h > params.dest.height() - || params.src_x + params.tile_w > params.atlas.width() - || params.src_y + params.tile_h > params.atlas.height() - { - return; - } - - for py in 0..params.tile_h { - for px in 0..params.tile_w { - let pixel = params.atlas.get_pixel(params.src_x + px, params.src_y + py); - let alpha = pixel[3]; - if alpha == 0 { - continue; - } - if alpha == 255 { - params.dest.put_pixel(dest_x + px, dest_y + py, pixel); - } else { - let existing = *params.dest.get_pixel(dest_x + px, dest_y + py); - let blend = |src: u8, dst: u8, a: u8| -> u8 { - ((src as u32 * a as u32 + dst as u32 * (255 - a as u32)) / 255) as u8 - }; - params.dest.put_pixel( - dest_x + px, - dest_y + py, - image::Rgba([ - blend(pixel[0], existing[0], alpha), - blend(pixel[1], existing[1], alpha), - blend(pixel[2], existing[2], alpha), - 255, - ]), - ); - } - } - } -} - -// -------------------------------------------------------------------------- -// Tests -// -------------------------------------------------------------------------- - #[test] fn rgb16_565_produce_color_test() { let color = rgb16_565_produce_color(0); @@ -1465,30 +1397,6 @@ fn rgb16_565_white() { assert_eq!(color.b, 248); } -#[test] -fn plot_atlas_tile_params() { - use image::{ImageBuffer, Rgba, RgbaImage}; - - let mut dest: RgbaImage = ImageBuffer::new(100, 100); - let atlas: image::DynamicImage = - image::DynamicImage::ImageRgba8(ImageBuffer::from_pixel(64, 64, Rgba([255, 0, 0, 255]))); - - plot_atlas_tile(AtlasTileParams { - dest: &mut dest, - atlas: &atlas, - src_x: 0, - src_y: 0, - tile_w: 32, - tile_h: 32, - dest_x: 10, - dest_y: 10, - }); - - let pixel = dest.get_pixel(10, 10); - assert_eq!(pixel[0], 255); - assert_eq!(pixel[3], 255); -} - #[test] fn test_blend_rgb() { let result = blend_rgb([255, 255, 255], [0, 0, 0], 128); From d8d9982b174e997bb07177208aec395c4561b6d7 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:13:15 +0200 Subject: [PATCH 27/49] Update the event_items table structure --- src/queries/create_table_event_items.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/queries/create_table_event_items.sql b/src/queries/create_table_event_items.sql index 1a1997de..8522d78a 100644 --- a/src/queries/create_table_event_items.sql +++ b/src/queries/create_table_event_items.sql @@ -1,8 +1,8 @@ CREATE TABLE IF NOT EXISTS event_items ( id INTEGER, - name STRING, - description STRING, + name TEXT, + description TEXT, base_price INTEGER, - padding BLOB + padding INTEGER ) From b0725db3cdeed3889fcef9ac8d53e0ba0667173d Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:42:25 +0200 Subject: [PATCH 28/49] Update the MagicType enum --- dispel-gui/src/editors/magic/component.rs | 2 +- docs/files/MagicInGame/Magic.db.md | 2 +- src/map/tileset.rs | 11 ++++- src/references/enums.rs | 59 ++++++++++++++++------- src/references/magic_db.rs | 6 +-- 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/dispel-gui/src/editors/magic/component.rs b/dispel-gui/src/editors/magic/component.rs index 2662b5f2..bf03169b 100644 --- a/dispel-gui/src/editors/magic/component.rs +++ b/dispel-gui/src/editors/magic/component.rs @@ -20,7 +20,7 @@ editable_record_fields!(MagicSpell, { { effect_type = Integer / "Effect Type:" }, { effect_modifier = Integer / "Effect Modifier:" }, { reserved_0x3c = Integer / "Reserved (0x3C):" }, - { magic_type = Enum(MagicType, ["Magic", "LightMagic", "BlackMagic"]) / "Magic Type:" }, + { magic_type = Enum(MagicType, ["Magic", "HolyMagic", "DarkMagic", "Fire", "Water", "Earth", "Air"]) / "Magic Type:" }, { target_animation_blends_with_background = Boolean / "Target Animation Blends:" }, { animation_set_id = Integer / "Animation Set ID:" }, { effect_visual_id = Integer / "Effect Visual ID:" }, diff --git a/docs/files/MagicInGame/Magic.db.md b/docs/files/MagicInGame/Magic.db.md index e5525251..28f2919d 100644 --- a/docs/files/MagicInGame/Magic.db.md +++ b/docs/files/MagicInGame/Magic.db.md @@ -29,7 +29,7 @@ All fields are little-endian `u32`; the record ID is its zero-based position. | 44 | `animation_data_index` | Index into the animation data table, resolved to a pointer after file load | | 48–56 | `effect_value`, `effect_type`, `effect_modifier` | Effect configuration; exact semantics are not yet established | | 60 | `reserved_0x3c` | Reserved word; zero by default | -| 64 | `magic_type` | Magic type (0=Magic, 1=LightMagic, 2=BlackMagic) — selects which character magic-skill attribute drives damage, success, and mana-cost calculations | +| 64 | `magic_type` | Magic type (0=Magic, 1=HolyMagic, 2=DarkMagic) — selects which character magic-skill attribute drives damage, success, and mana-cost calculations | | 68 | `target_animation_blends_with_background` | `0`=Off, `1`=On — uses blended rendering for the target animation instead of direct blitting | | 72 | `animation_set_id` | Cast-animation set | | 76 | `effect_visual_id` | Visual/projectile mapping selected when casting | diff --git a/src/map/tileset.rs b/src/map/tileset.rs index 8f69d5d8..2e9b5f67 100644 --- a/src/map/tileset.rs +++ b/src/map/tileset.rs @@ -138,8 +138,15 @@ pub fn extract(source_path: &Path) -> Result> { /// Creates a mask for isometric tile rendering. /// -/// Returns `[[x_offset, width]; 32]` — one entry per scanline of the 32-row tile. -/// The mask describes the diamond shape: narrow at top/bottom, full-width in the middle. +/// Returns `[[x_offset, width]; 32]` — one entry per scanline of the +/// 32-row tile. The mask describes a 62px-wide ([`TILE_WIDTH`]) diamond: +/// narrow at top/bottom, full-width across the two middle rows. Row widths +/// run 2, 6, …, 62, 62, …, 6, 2 and consume exactly `TILE_PIXEL_NUMBER` +/// (1024) texels per tile. +/// +/// Tiling geometry: diagonal neighbours step (±32, ∓16) and abut +/// edge-to-edge; horizontal neighbours step 64px, leaving a 2px seam +/// between the widest rows (the visible isometric grid line). fn create_mask() -> [[i32; 2]; 32] { let mut mask = [[0i32; 2]; TILE_HEIGHT as usize]; let mut pixels_x: i32 = 1; diff --git a/src/references/enums.rs b/src/references/enums.rs index 6965d652..9f49df51 100644 --- a/src/references/enums.rs +++ b/src/references/enums.rs @@ -1530,18 +1530,27 @@ impl From for HealItemFlag { /// Magic type classification for spells. /// -/// Each spell belongs to one of three types. The player's proficiency in a -/// type rises as they cast spells of that type. +/// Each spell belongs to one of seven types: three schools of magic plus four +/// elemental types. The player's proficiency in a type rises as they cast +/// spells of that type. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[repr(u32)] pub enum MagicType { /// Normal magic (healing, basic spells) #[default] Magic = 0, - /// Light/Holy magic (protection, buffs) - LightMagic = 1, - /// Black magic (poison, curses) - BlackMagic = 2, + /// Holy magic (protection, buffs) + HolyMagic = 1, + /// Dark magic (poison, curses) + DarkMagic = 2, + /// Fire elemental magic + Fire = 3, + /// Water elemental magic + Water = 4, + /// Earth elemental magic + Earth = 5, + /// Air elemental magic + Air = 6, } impl MagicType { @@ -1549,8 +1558,12 @@ impl MagicType { pub fn from_u32(value: u32) -> Option { match value { 0 => Some(MagicType::Magic), - 1 => Some(MagicType::LightMagic), - 2 => Some(MagicType::BlackMagic), + 1 => Some(MagicType::HolyMagic), + 2 => Some(MagicType::DarkMagic), + 3 => Some(MagicType::Fire), + 4 => Some(MagicType::Water), + 5 => Some(MagicType::Earth), + 6 => Some(MagicType::Air), _ => None, } } @@ -1564,8 +1577,12 @@ impl MagicType { pub fn from_name(name: &str) -> Option { match name { "Magic" => Some(MagicType::Magic), - "LightMagic" => Some(MagicType::LightMagic), - "BlackMagic" => Some(MagicType::BlackMagic), + "HolyMagic" => Some(MagicType::HolyMagic), + "DarkMagic" => Some(MagicType::DarkMagic), + "Fire" => Some(MagicType::Fire), + "Water" => Some(MagicType::Water), + "Earth" => Some(MagicType::Earth), + "Air" => Some(MagicType::Air), _ => None, } } @@ -2153,17 +2170,25 @@ mod tests { #[test] fn test_magic_type_conversion() { assert_eq!(MagicType::from_u32(0), Some(MagicType::Magic)); - assert_eq!(MagicType::from_u32(1), Some(MagicType::LightMagic)); - assert_eq!(MagicType::from_u32(2), Some(MagicType::BlackMagic)); - assert_eq!(MagicType::from_u32(3), None); + assert_eq!(MagicType::from_u32(1), Some(MagicType::HolyMagic)); + assert_eq!(MagicType::from_u32(2), Some(MagicType::DarkMagic)); + assert_eq!(MagicType::from_u32(3), Some(MagicType::Fire)); + assert_eq!(MagicType::from_u32(4), Some(MagicType::Water)); + assert_eq!(MagicType::from_u32(5), Some(MagicType::Earth)); + assert_eq!(MagicType::from_u32(6), Some(MagicType::Air)); + assert_eq!(MagicType::from_u32(7), None); assert_eq!(MagicType::from_u32(99), None); - assert_eq!(u32::from(MagicType::BlackMagic), 2); - assert_eq!(MagicType::BlackMagic.value(), 2); + assert_eq!(u32::from(MagicType::DarkMagic), 2); + assert_eq!(MagicType::DarkMagic.value(), 2); + assert_eq!(u32::from(MagicType::Fire), 3); + assert_eq!(u32::from(MagicType::Air), 6); assert_eq!( - MagicType::from_name("LightMagic"), - Some(MagicType::LightMagic) + MagicType::from_name("HolyMagic"), + Some(MagicType::HolyMagic) ); + assert_eq!(MagicType::from_name("Fire"), Some(MagicType::Fire)); + assert_eq!(MagicType::from_name("Air"), Some(MagicType::Air)); } #[test] diff --git a/src/references/magic_db.rs b/src/references/magic_db.rs index 85f01d53..b15feaf2 100644 --- a/src/references/magic_db.rs +++ b/src/references/magic_db.rs @@ -146,7 +146,7 @@ pub struct MagicSpell { #[extractor(primitive(type = "u32"))] pub reserved_0x3c: u32, - /// Magic type (Magic=0, LightMagic=1, BlackMagic=2) — selects which character + /// Magic type (Magic=0, HolyMagic=1, DarkMagic=2) — selects which character /// magic-skill attribute drives damage, success, and mana-cost calculations. #[extractor(enum_from_u32(type = "MagicType"))] pub magic_type: MagicType, @@ -248,7 +248,7 @@ mod tests { 1, // effect_type 0, // effect_modifier 0, // reserved_0x3c - 1, // magic_type (LightMagic) + 1, // magic_type (HolyMagic) 0, // target_animation_blends_with_background 1, // animation_set_id 2, // effect_visual_id @@ -271,7 +271,7 @@ mod tests { assert_eq!(spells[0].enabled, MagicSpellFlag::Enabled); assert_eq!(spells[0].base_damage, 20); assert_eq!(spells[0].mana_cost, 50); - assert_eq!(spells[0].magic_type, MagicType::LightMagic); + assert_eq!(spells[0].magic_type, MagicType::HolyMagic); assert_eq!(spells[0].targeting_mode, SpellTargetType::Single); } From 964217c7a2dc526d67517f029dc1e44802f2a2a4 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:27:47 +0200 Subject: [PATCH 29/49] Update map renderer --- src/cli.rs | 4 + src/commands/map.rs | 2 + src/map/mod.rs | 4 + src/map/render.rs | 328 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 311 insertions(+), 27 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 1eb089c6..fb095fc9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -249,6 +249,10 @@ pub enum MapCommands { /// Disable external objects (extras) rendering #[arg(long)] no_objects: bool, + /// Animation frame index for internal sprites (default 0; clamped to + /// each sequence's last frame) + #[arg(long)] + frame: Option, // Canvas mode /// Output RGBA PNG with alpha channel (black background pixels become transparent) diff --git a/src/commands/map.rs b/src/commands/map.rs index fe454174..54e0c560 100644 --- a/src/commands/map.rs +++ b/src/commands/map.rs @@ -47,6 +47,7 @@ impl Command for MapCommand { no_monsters, no_npcs, no_objects, + frame, transparent, collisions, events, @@ -75,6 +76,7 @@ impl Command for MapCommand { show_events: *events, show_draw_items: *draw_items, show_npc_waypoints: *npc_waypoints, + sprite_frame: *frame, }, ) .map_err(|e| format!("ERROR: could not render map: {e}"))?; diff --git a/src/map/mod.rs b/src/map/mod.rs index 4ed5efa6..6dfa7d3a 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -535,6 +535,7 @@ pub fn extract( map_id, game_path, toggles, + lights: &[], }) } @@ -911,3 +912,6 @@ mod shadow_tests { assert_eq!(levels, vec![((1, 0), 100), ((4, 0), 199)]); } } + +#[cfg(test)] +mod render_placement_tests; diff --git a/src/map/render.rs b/src/map/render.rs index 2184a2f8..c469b449 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -42,6 +42,9 @@ pub struct LayerToggles { pub show_events: bool, pub show_draw_items: bool, pub show_npc_waypoints: bool, + /// Frame index used for internal sprites' animations (`None` = frame 0). + /// Out-of-range indices are clamped to the last frame of each sequence. + pub sprite_frame: Option, } impl Default for LayerToggles { @@ -60,6 +63,7 @@ impl Default for LayerToggles { show_events: false, show_draw_items: false, show_npc_waypoints: false, + sprite_frame: None, } } } @@ -94,6 +98,27 @@ pub struct ExternalEntities { // Top-level render entry point // -------------------------------------------------------------------------- +/// A runtime light source carried by an entity (torch, lantern, spell effect…). +/// +/// Light data is runtime state — it is **not** stored in map files — so stills +/// receive lights explicitly through [`MapRenderConfig::lights`]. On tiles +/// whose access-ref word has the light-source flag bits (30–31) set, the +/// effective shadow level becomes `max(static_level, covering_light_level)` +/// ("max wins", see `reader.rs` word layout). Radius is Chebyshev distance in +/// tile units; the flat-inside-radius falloff model is an approximation until +/// validated against game captures (`docs/rendering_discrepancies.md` §1.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EntityLight { + /// Light position in map tile coordinates. + pub x: i32, + pub y: i32, + /// Reach in tile units (Chebyshev). Tiles at distance ≤ radius are lit. + pub radius: u16, + /// Light level contributed to covered tiles (`1..=123`; higher values + /// leave a tile untouched by the fade pass, like static levels ≥ 124). + pub level: u16, +} + /// Configuration for rendering a map pub struct MapRenderConfig<'a> { pub reader: &'a mut BufReader, @@ -104,6 +129,9 @@ pub struct MapRenderConfig<'a> { pub map_id: &'a str, pub game_path: Option<&'a Path>, pub toggles: LayerToggles, + /// Runtime entity-carried lights for the shadow pass on Dark maps + /// (see [`EntityLight`]). Empty = static lighting only. + pub lights: &'a [EntityLight], } /// Renders the full map to a PNG file. @@ -123,6 +151,7 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { map_id, game_path, toggles, + lights, } = config; // The renderer reproduces the observed occluded viewport — the region @@ -146,7 +175,15 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { } // ── Pre-load external entities (if game path given) ────────────────── - let external = game_path.and_then(|gp| collect_external_entities(map_id, gp, &data.model).ok()); + let external = game_path.and_then(|gp| { + match collect_external_entities(map_id, gp, &data.model) { + Ok(entities) => Some(entities), + Err(e) => { + eprintln!("WARNING: no external entities rendered for map '{map_id}': {e}"); + None + } + } + }); // ── Pass 2: Interleaved objects + entities ─────────────────────────── // All depth-relevant items (buildings, internal sprites, monsters, NPCs, @@ -193,7 +230,9 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { if toggles.show_internal_sprites { for (i, block) in data.sprite_blocks.iter().enumerate() { if block.sprite_id < data.internal_sprites.len() { - let h = data.internal_sprites[block.sprite_id].frame_infos[0].height; + let infos = &data.internal_sprites[block.sprite_id].frame_infos; + let f = toggles.sprite_frame.map_or(0, |f| f.min(infos.len() - 1)); + let h = infos[f].height; let pos = internal_sprite_sort_key(block.sprite_y + h); items.push((pos, 1, block.sprite_x, ItemKind::Sprite(i))); } @@ -241,7 +280,9 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { ItemKind::Sprite(i) => { let block = &data.sprite_blocks[*i]; let sequence = &data.internal_sprites[block.sprite_id]; - let sprite = &sequence.frame_infos[0]; + let f = + toggles.sprite_frame.map_or(0, |f| f.min(sequence.frame_infos.len() - 1)); + let sprite = &sequence.frame_infos[f]; let dest_x = block.sprite_x; let dest_y = block.sprite_y; plot_sprite_on_bitmap(&mut imgbuf, reader, sprite, dest_x, dest_y)?; @@ -302,7 +343,7 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { if toggles.show_shadows && let Some(fog) = prepare_shadow_pass(game_path, map_id) { - plot_shadows(&mut imgbuf, &data.model, &data.access_ref_words, &fog); + plot_shadows(&mut imgbuf, &data.model, &data.access_ref_words, &fog, lights); } // ── Pass 4: Overlays ───────────────────────────────────────────────── @@ -490,8 +531,13 @@ pub fn plot_roofs( // -------------------------------------------------------------------------- /// Per-row `(x_start, width)` spans of the shadow mask (32 rows). The rows -/// trace the 64×32 isometric tile diamond; each row is processed as -/// `width/2` pixel pairs sharing one fade factor. +/// trace the 62×32 isometric tile diamond (`TILE_WIDTH` × `TILE_HEIGHT`); +/// each row is processed as `width/2` pixel pairs sharing one fade factor. +/// +/// Geometry: diagonal neighbours step (±32, ∓16), so diamonds abut +/// edge-to-edge without overlapping — every canvas pixel belongs to at most +/// one shadow tile. Horizontal neighbours step 64px, leaving a 2px seam +/// between the widest rows. pub const SHADOW_SPANS: [(i32, i32); 32] = [ (30, 2), (28, 6), @@ -578,16 +624,41 @@ fn prepare_shadow_pass(game_path: Option<&Path>, map_id: &str) -> Option, Vec>, model: &MapModel, words: &HashMap, fog: &FogData, + lights: &[EntityLight], ) { let map_diagonal_tiles = model.tiled_map_width + model.tiled_map_height; + // Pair indices are positional within the diamond: row r starts at + // `row_pair_base[r]`. A running counter would desync whenever a row or + // pixel is clipped off-canvas, reading the wrong flicker-phase byte. + let mut row_pair_base = [0usize; SHADOW_SPANS.len()]; + for r in 1..SHADOW_SPANS.len() { + row_pair_base[r] = row_pair_base[r - 1] + SHADOW_SPANS[r - 1].1 as usize / 2; + } + for (&(x, y), &word) in words { - let level = (word >> 15) & 0x7FFF; + let static_level = (word >> 15) & 0x7FFF; + let flags = (word >> 30) & 0b11; + let mut level = static_level; + if flags != 0 && !lights.is_empty() { + for light in lights { + let dx = (x - light.x).abs(); + let dy = (y - light.y).abs(); + if dx.max(dy) <= i32::from(light.radius) { + level = level.max(u32::from(light.level)); + } + } + } if level as usize > super::fogdata::ROWS { continue; } @@ -596,13 +667,15 @@ fn plot_shadows( px -= model.map_non_occluded_start_x; py -= model.map_non_occluded_start_y; - let mut pair = 0usize; for (row, &(start, width)) in SHADOW_SPANS.iter().enumerate() { let py_row = py + row as i32; if py_row < 0 { continue; } for p in 0..(width as usize / 2) { + // Positional pair index — immune to clipping (see + // `row_pair_base` above). + let pair = row_pair_base[row] + p; let x0 = px + start + (p * 2) as i32; if x0 < 0 { continue; @@ -628,7 +701,6 @@ fn plot_shadows( } } } - pair += 1; } } } @@ -638,6 +710,28 @@ fn plot_shadows( // External entity collection and rendering // -------------------------------------------------------------------------- +/// Reads an INI entity catalog, reporting (not swallowing) failures. +/// +/// A corrupt or missing file yields an empty catalog so the remaining entity +/// kinds still render, but the problem is reported loudly on stderr — silent +/// zero-entity renders have bitten us before (see +/// `docs/rendering_discrepancies.md` §6.1). +fn read_ini_catalog(label: &str, path: &Path) -> Vec +where + T: crate::references::extractor::Extractor, +{ + match ::read_file(path) { + Ok(catalog) => catalog, + Err(e) => { + eprintln!( + "WARNING: could not read {label} from {}: {e} — no entities of this kind will be rendered", + path.display() + ); + Vec::new() + } + } +} + /// Loads all external entity data (monsters, NPCs, extras, draw items) for the /// given map from the game data files. pub fn collect_external_entities( @@ -718,23 +812,22 @@ pub fn collect_external_entities( }; let monster_sprite_map: HashMap = - MonsterIni::read_file(&game_path.join("Monster.ini")) - .unwrap_or_default() + read_ini_catalog::("Monster.ini", &game_path.join("Monster.ini")) .into_iter() .filter_map(|m| m.sprite_filename.map(|s| (m.id, s))) .collect(); - let npc_sprite_map: HashMap = NpcIni::read_file(&game_path.join("Npc.ini")) - .unwrap_or_default() - .into_iter() - .filter_map(|n| n.sprite_filename.map(|s| (n.id, s))) - .collect(); + let npc_sprite_map: HashMap = + read_ini_catalog::("Npc.ini", &game_path.join("Npc.ini")) + .into_iter() + .filter_map(|n| n.sprite_filename.map(|s| (n.id, s))) + .collect(); - let extra_sprite_map: HashMap = Extra::read_file(&game_path.join("Extra.ini")) - .unwrap_or_default() - .into_iter() - .filter_map(|e| e.sprite_filename.map(|s| (e.id, s))) - .collect(); + let extra_sprite_map: HashMap = + read_ini_catalog::("Extra.ini", &game_path.join("Extra.ini")) + .into_iter() + .filter_map(|e| e.sprite_filename.map(|s| (e.id, s))) + .collect(); let mut monsters = Vec::new(); let mut npcs = Vec::new(); @@ -912,7 +1005,11 @@ fn render_entity_sprite( } } -/// Blit an RGBA sprite onto an RGB destination image. Transparent pixels are skipped. +/// Blit an RGBA sprite onto an RGB destination image. +/// +/// Per-pixel alpha is honored: `a == 0` skips, `a == 255` replaces, anything +/// in between blends linearly (`src*a + dst*(255-a)`) — matching soft sprite +/// edges and ghost effects in the game. fn plot_rgba_sprite_on_rgb( dest: &mut ImageBuffer, Vec>, sprite: &image::RgbaImage, @@ -936,12 +1033,24 @@ fn plot_rgba_sprite_on_rgb( sx as u32 }; let pixel = *sprite.get_pixel(src_x, sy as u32); - if pixel[3] == 0 { + let a = u32::from(pixel[3]); + if a == 0 { continue; } let px = dest_x + sx; if px >= 0 && px < dw { - dest.put_pixel(px as u32, py as u32, Rgb([pixel[0], pixel[1], pixel[2]])); + let dst = dest.get_pixel(px as u32, py as u32); + let mix = + |s: u8, d: u8| ((u32::from(s) * a + u32::from(d) * (255 - a)) / 255) as u8; + dest.put_pixel( + px as u32, + py as u32, + Rgb([ + mix(pixel[0], dst[0]), + mix(pixel[1], dst[1]), + mix(pixel[2], dst[2]), + ]), + ); } } } @@ -1493,7 +1602,7 @@ fn level_zero_tile_is_blacked_out_inside_spans_only() { let fog = super::fogdata::FogData::from_raw(vec![31; super::fogdata::EXPECTED_LEN]); let mut words = HashMap::new(); words.insert((0, 0), 0u32); - plot_shadows(&mut img, &test_model(), &words, &fog); + plot_shadows(&mut img, &test_model(), &words, &fog, &[]); // Center of the diamond: solid black. assert_eq!(img.get_pixel((px + 32) as u32, (py + 16) as u32)[0], 0); @@ -1514,7 +1623,7 @@ fn lit_tiles_scale_red_green_pairwise_and_keep_blue() { let (mut img, px, py) = shadow_test_canvas(); let mut words = HashMap::new(); words.insert((0, 0), 1u32 << 15); - plot_shadows(&mut img, &test_model(), &words, &fog); + plot_shadows(&mut img, &test_model(), &words, &fog, &[]); // First span row: start=30, width=2 → one pair at x=px+30. let f0 = u32::from(fog.factor(1, 0)); @@ -1545,7 +1654,7 @@ fn levels_at_or_above_200_are_untouched() { let fog = super::fogdata::FogData::from_raw(vec![0; super::fogdata::EXPECTED_LEN]); let mut words = HashMap::new(); words.insert((0, 0), level << 15); - plot_shadows(&mut img, &test_model(), &words, &fog); + plot_shadows(&mut img, &test_model(), &words, &fog, &[]); assert!( img.pixels().all(|p| *p == Rgb([200, 100, 50])), "level {level} tiles must not be modified" @@ -1553,6 +1662,171 @@ fn levels_at_or_above_200_are_untouched() { } } +/// Fog table with a dark row for level 1 (f=2) and a bright row for level 65 +/// (f=31); all other rows zeroed. +#[cfg(test)] +fn two_level_fog() -> super::fogdata::FogData { + let mut data = vec![0u8; super::fogdata::EXPECTED_LEN]; + data[0..512].fill(2); // row 0 serves level 1 + data[64 * 512..65 * 512].fill(31); // row 64 serves level 65 + super::fogdata::FogData::from_raw(data) +} + +#[test] +fn dynamic_entity_light_raises_only_flagged_tiles() { + // Bits 30–31 gate the dynamic pass: a light covering both tiles raises + // only the flagged one. The unflagged tile keeps its static fade even + // though the light reaches it ("entities' light raises the level" applies + // solely to tiles marked as light-responsive). + let fog = two_level_fog(); + let lights = [EntityLight { + x: 0, + y: 0, + radius: 5, + level: 65, + }]; + + // Flagged tile: static level 1 + light-flag bits set. + let mut flagged = HashMap::new(); + flagged.insert((0, 0), (1u32 << 15) | (0b11 << 30)); + // Unflagged twin: identical static level, no flag bits. + let mut unflagged = HashMap::new(); + unflagged.insert((0, 0), 1u32 << 15); + + let (mut base_img, px, py) = shadow_test_canvas(); + plot_shadows(&mut base_img, &test_model(), &flagged, &fog, &[]); + let (mut lit_img, _, _) = shadow_test_canvas(); + plot_shadows(&mut lit_img, &test_model(), &flagged, &fog, &lights); + + // Diamond-center pixel: dark under static level 1, bright when raised + // to level 65 by the entity light. + let (cx, cy) = ((px + 32) as u32, (py + 16) as u32); + assert_eq!(base_img.get_pixel(cx, cy)[0], (200 * 2 / 32) as u8); + assert_eq!(lit_img.get_pixel(cx, cy)[0], (200 * 31 / 32) as u8); + + // Same experiment on the unflagged tile: the light must change nothing. + let (mut base2, _, _) = shadow_test_canvas(); + plot_shadows(&mut base2, &test_model(), &unflagged, &fog, &[]); + let (mut lit2, _, _) = shadow_test_canvas(); + plot_shadows(&mut lit2, &test_model(), &unflagged, &fog, &lights); + assert_eq!( + base2.pixels().collect::>(), + lit2.pixels().collect::>() + ); +} + +#[test] +fn semi_transparent_sprite_pixels_blend_with_background() { + // 1×2 sprite: left pixel half-transparent red, right pixel opaque green. + let mut sprite = image::RgbaImage::new(2, 1); + sprite.put_pixel(0, 0, image::Rgba([200, 0, 0, 128])); + sprite.put_pixel(1, 0, image::Rgba([0, 255, 0, 255])); + + let mut dest = ImageBuffer::from_pixel(4, 1, Rgb([100, 100, 100])); + plot_rgba_sprite_on_rgb(&mut dest, &sprite, 0, 0, false); + + // a=128: (src*128 + dst*127) / 255. + let expected = ((200 * 128 + 100 * 127) / 255) as u8; + assert_eq!(dest.get_pixel(0, 0)[0], expected, "red channel blended"); + // Green: src contributes 0 → background scaled by (255-128). + assert_eq!(dest.get_pixel(0, 0)[1], ((100 * 127) / 255) as u8); + // Fully opaque pixel replaces the background outright. + assert_eq!(*dest.get_pixel(1, 0), Rgb([0u8, 255, 0])); +} + +#[test] +fn dynamic_entity_light_max_wins_over_static() { + // A dim light (level 1) covering a brightly lit flagged tile (static 65) + // must not darken it: effective level = max(65, 1) = 65. + let fog = two_level_fog(); + let mut words = HashMap::new(); + words.insert((0, 0), (65u32 << 15) | (0b11 << 30)); + let lights = [EntityLight { + x: 0, + y: 0, + radius: 3, + level: 1, + }]; + + let (mut base_img, px, py) = shadow_test_canvas(); + plot_shadows(&mut base_img, &test_model(), &words, &fog, &[]); + let (mut lit_img, _, _) = shadow_test_canvas(); + plot_shadows(&mut lit_img, &test_model(), &words, &fog, &lights); + + assert_eq!( + base_img.pixels().collect::>(), + lit_img.pixels().collect::>(), + "dimmer light must never reduce a brighter static level" + ); + // Sanity: the tile actually received the bright static fade. + assert_eq!(base_img.get_pixel((px + 32) as u32, (py + 16) as u32)[0], (200 * 31 / 32) as u8); +} + +#[test] +fn clipped_shadow_rows_keep_positional_pair_indices() { + // A tile whose top diamond rows fall above the canvas must still look up + // its flicker-phase bytes by POSITION. A running counter used to freeze + // at the clip boundary, desyncing every later pair. + let mut data = vec![31u8; super::fogdata::EXPECTED_LEN]; + data[0] = 2; // row 0's single pair is distinctly dark + let fog = super::fogdata::FogData::from_raw(data); + + // Shift the viewport down so local row 0 lands at global y = -1. + let model = MapModel { + tiled_map_width: 1, + tiled_map_height: 1, + map_non_occluded_start_y: 17, // convert(0,0).1 = 16 → py = -1 + ..MapModel::default() + }; + let diagonal = model.tiled_map_width + model.tiled_map_height; + let (px, py) = convert_map_coords_to_image_coords(0, 0, diagonal); + assert_eq!(py - model.map_non_occluded_start_y, -1); + + let mut words = HashMap::new(); + words.insert((0, 0), (1u32 << 15) | (0b11 << 30)); + + let mut img = image::ImageBuffer::, Vec>::from_pixel(128, 128, Rgb([200, 100, 50])); + plot_shadows(&mut img, &model, &words, &fog, &[]); + + // Local row 1 (global y = 0): span (28, 6), first pixel-pair p=0 → + // positional pair index 1 (row 0 contributed one pair). Factor must be + // 31 — a stale counter would have read data[0] = 2. + assert_eq!( + img.get_pixel((px + 28) as u32, 0)[0], + (200 * 31 / 32) as u8, + "first visible row must use its positional pair index" + ); +} + +#[test] +fn fog_loads_only_for_dark_maps() { + let gp = Path::new("fixtures/Dispel"); + if !gp.exists() { + eprintln!("Skipping: fixtures not found"); + return; + } + use crate::references::all_map_ini::read_all_map_ini; + use crate::references::enums::MapLighting; + + let maps = read_all_map_ini(&gp.join("AllMap.ini")).expect("parse AllMap.ini"); + let Some(dark) = maps.iter().find(|m| m.lighting == MapLighting::Dark) else { + eprintln!("Skipping: no Dark map in fixture AllMap.ini"); + return; + }; + assert!( + load_fog_if_dark(gp, &dark.map_filename).is_some(), + "Dark map '{}' must load fog tables", + dark.map_filename + ); + if let Some(light) = maps.iter().find(|m| m.lighting != MapLighting::Dark) { + assert!( + load_fog_if_dark(gp, &light.map_filename).is_none(), + "Light map '{}' must not load fog tables", + light.map_filename + ); + } +} + #[test] fn fogdata_loads_from_fixture_game_dir() { let fog = FogData::load(Path::new("fixtures/Dispel")).expect("fixture fogdata.dat"); From b41fb041bfe140447d4b6717b64158573d708a80 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:29:54 +0200 Subject: [PATCH 30/49] Add copy file path in the file tree --- dispel-gui/src/components/file_tree/data.rs | 32 +++++++++- .../src/components/file_tree/message.rs | 4 ++ dispel-gui/src/update/file_tree.rs | 59 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/dispel-gui/src/components/file_tree/data.rs b/dispel-gui/src/components/file_tree/data.rs index 90c5701f..4a96d409 100644 --- a/dispel-gui/src/components/file_tree/data.rs +++ b/dispel-gui/src/components/file_tree/data.rs @@ -402,7 +402,7 @@ fn render_node<'a>( icon_char(Icon::ChevronRight) }; - button( + let dir_btn = button( row![ text(caret_char) .font(LUCIDE_FONT) @@ -421,8 +421,25 @@ fn render_node<'a>( right: 4.0, bottom: 3.0, left: 6.0, - }) - .into() + }); + + let entries = vec![ + Entry::item( + "Show in File Manager", + FileTreeMessage::ShowInFileManager(node.path.clone()), + ), + Entry::separator(), + Entry::item( + "Copy Absolute Path", + FileTreeMessage::CopyAbsolutePath(node.path.clone()), + ), + Entry::item( + "Copy Relative Path", + FileTreeMessage::CopyRelativePath(node.path.clone()), + ), + ]; + + ContextMenu::new(dir_btn, entries).into() } else { let name_element = create_highlighted_text(&node.name, tree_filter.search_query()); @@ -456,6 +473,15 @@ fn render_node<'a>( "Show in File Manager", FileTreeMessage::ShowInFileManager(node.path.clone()), ), + Entry::separator(), + Entry::item( + "Copy Absolute Path", + FileTreeMessage::CopyAbsolutePath(node.path.clone()), + ), + Entry::item( + "Copy Relative Path", + FileTreeMessage::CopyRelativePath(node.path.clone()), + ), ]; ContextMenu::new(file_btn, entries).into() diff --git a/dispel-gui/src/components/file_tree/message.rs b/dispel-gui/src/components/file_tree/message.rs index eac46027..e46d8491 100644 --- a/dispel-gui/src/components/file_tree/message.rs +++ b/dispel-gui/src/components/file_tree/message.rs @@ -18,4 +18,8 @@ pub enum FileTreeMessage { ExtractToJson(PathBuf), ValidateFile(PathBuf), ShowInFileManager(PathBuf), + CopyAbsolutePath(PathBuf), + CopyRelativePath(PathBuf), + /// Acknowledgement for a completed clipboard write (no-op). + ClipboardWriteComplete, } diff --git a/dispel-gui/src/update/file_tree.rs b/dispel-gui/src/update/file_tree.rs index ab89253f..a6bf4c3b 100644 --- a/dispel-gui/src/update/file_tree.rs +++ b/dispel-gui/src/update/file_tree.rs @@ -88,5 +88,64 @@ pub fn handle(message: FileTreeMessage, app: &mut App) -> Task { + copy_to_clipboard(app, file_path.display().to_string()) + } + FileTreeMessage::CopyRelativePath(file_path) => { + let text = relative_display(&app.state.shared_game_path, &file_path); + copy_to_clipboard(app, text) + } + FileTreeMessage::ClipboardWriteComplete => Task::none(), + } +} + +/// Write `text` to the system clipboard and report it in the status bar. +fn copy_to_clipboard(app: &mut App, text: String) -> Task { + app.state.status_msg = format!("Copied to clipboard: {text}"); + iced::clipboard::write(text) + .map(|_| crate::message::Message::file_tree(FileTreeMessage::ClipboardWriteComplete)) +} + +/// Path relative to the game directory; falls back to the full path when the +/// game path is unset or the file is outside of it. +fn relative_display(shared_game_path: &str, path: &std::path::Path) -> String { + if shared_game_path.is_empty() { + return path.display().to_string(); + } + match path.strip_prefix(std::path::Path::new(shared_game_path)) { + Ok(relative) => relative.display().to_string(), + Err(_) => path.display().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_display_strip_prefix_when_under_game_path() { + assert_eq!( + relative_display( + "/game", + std::path::Path::new("/game/CharacterInGame/weaponItem.db") + ), + "CharacterInGame/weaponItem.db" + ); + } + + #[test] + fn test_relative_display_falls_back_to_full_path_outside_game() { + assert_eq!( + relative_display("/game", std::path::Path::new("/other/file.db")), + "/other/file.db" + ); + } + + #[test] + fn test_relative_display_falls_back_when_game_path_empty() { + assert_eq!( + relative_display("", std::path::Path::new("/other/file.db")), + "/other/file.db" + ); } } From 1355804dbc571bd89c9c7b0c1f8a2570eb2db869 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:13:24 +0200 Subject: [PATCH 31/49] Export more map data --- .../src/editors/map_editor/canvas/tests.rs | 3 + .../src/editors/map_editor/update/entity.rs | 3 + .../src/editors/map_editor/update/map.rs | 3 + .../editors/map_editor/update/persistence.rs | 1 + src/commands/database.rs | 22 + src/database.rs | 12 + src/map/fogdata.rs | 63 +++ src/map/mod.rs | 458 +++++++++++++++++- src/map/reader.rs | 102 +++- src/map/render.rs | 37 +- src/map/types.rs | 32 ++ src/map/writer.rs | 3 + src/queries/create_table_fog_factors.sql | 7 + .../create_table_map_object_metadata.sql | 18 + src/queries/create_table_map_object_refs.sql | 7 + .../create_table_map_overlay_modes.sql | 8 + .../create_table_map_sprite_frames.sql | 2 + .../create_table_map_sprite_sequences.sql | 8 + src/queries/create_table_map_sprites.sql | 4 + src/queries/create_table_map_tiles.sql | 6 + src/queries/insert_fog_factor.sql | 2 + src/queries/insert_map_object_metadata.sql | 2 + src/queries/insert_map_object_ref.sql | 2 + src/queries/insert_map_overlay_mode.sql | 2 + src/queries/insert_map_sprite.sql | 4 +- src/queries/insert_map_sprite_frame.sql | 4 +- src/queries/insert_map_sprite_sequence.sql | 2 + src/queries/insert_map_tile.sql | 4 +- src/references/map_ini.rs | 36 +- 29 files changed, 800 insertions(+), 57 deletions(-) create mode 100644 src/queries/create_table_fog_factors.sql create mode 100644 src/queries/create_table_map_object_metadata.sql create mode 100644 src/queries/create_table_map_object_refs.sql create mode 100644 src/queries/create_table_map_overlay_modes.sql create mode 100644 src/queries/create_table_map_sprite_sequences.sql create mode 100644 src/queries/insert_fog_factor.sql create mode 100644 src/queries/insert_map_object_metadata.sql create mode 100644 src/queries/insert_map_object_ref.sql create mode 100644 src/queries/insert_map_overlay_mode.sql create mode 100644 src/queries/insert_map_sprite_sequence.sql diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index d9b61c09..f7e10fc5 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -62,6 +62,9 @@ fn make_state( tiled_infos: vec![], internal_sprites: vec![], sprite_blocks: vec![], + internal_sprite_stamps: Vec::new(), + tiled_object_refs: Vec::new(), + tiled_object_metadata: Vec::new(), }; let mut state = MapEditorState::default(); diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 91b02354..4b61c839 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -515,6 +515,9 @@ mod tests { tiled_infos: vec![], internal_sprites: vec![], sprite_blocks: vec![], + internal_sprite_stamps: Vec::new(), + tiled_object_refs: Vec::new(), + tiled_object_metadata: Vec::new(), }; state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index 2165710b..5a1b4577 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -1009,6 +1009,9 @@ mod tests { tiled_infos: vec![], internal_sprites: vec![], sprite_blocks: vec![], + internal_sprite_stamps: Vec::new(), + tiled_object_refs: Vec::new(), + tiled_object_metadata: Vec::new(), }; let state = app.state.editors.map_editors.entry(TAB).or_default(); state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); diff --git a/dispel-gui/src/editors/map_editor/update/persistence.rs b/dispel-gui/src/editors/map_editor/update/persistence.rs index 5c7cb3b9..f01b013c 100644 --- a/dispel-gui/src/editors/map_editor/update/persistence.rs +++ b/dispel-gui/src/editors/map_editor/update/persistence.rs @@ -365,6 +365,7 @@ pub fn export_image(app: &mut App, tab_id: usize) -> Task { map_id: &map_id, game_path: game_path.as_deref(), toggles: Default::default(), + lights: &[], }) .map_err(|e| format!("Render failed: {e}"))?; diff --git a/src/commands/database.rs b/src/commands/database.rs index 6a440096..869c0922 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -191,6 +191,18 @@ fn import_maps(main_path: &Path, conn: &mut Connection) -> Result<(), Box 0, "maps table should be populated"); + + if game_path.join("ExtraInGame/fogdata.dat").exists() { + let fog_count: i64 = conn + .query_row("SELECT COUNT(*) FROM fog_factors", [], |row| row.get(0)) + .expect("Failed to query fog_factors"); + assert_eq!( + fog_count, 62976, + "fog_factors must hold one row per fogdata.dat byte" + ); + } } if game_path.join("CharacterInGame/weaponItem.db").exists() { diff --git a/src/database.rs b/src/database.rs index 54006460..89f11a90 100644 --- a/src/database.rs +++ b/src/database.rs @@ -30,12 +30,17 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { "extra_refs", "extra_ref_files", "extras", + "fog_factors", "heal_items", "magic_spells", "map_inis", "map_metadata", + "map_object_metadata", + "map_object_refs", "map_objects", + "map_overlay_modes", "map_sprite_frames", + "map_sprite_sequences", "map_sprites", "map_tiles", "maps", @@ -71,6 +76,7 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("queries/create_table_messages.sql"))?; conn.execute_batch(include_str!("queries/create_table_events.sql"))?; conn.execute_batch(include_str!("queries/create_table_extras.sql"))?; + conn.execute_batch(include_str!("queries/create_table_fog_factors.sql"))?; conn.execute_batch(include_str!("queries/create_table_extra_ref_files.sql"))?; conn.execute_batch(include_str!("queries/create_table_extra_refs.sql"))?; conn.execute_batch(include_str!("queries/create_table_weapons.sql"))?; @@ -100,6 +106,12 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("queries/create_table_map_sprites.sql"))?; conn.execute_batch(include_str!("queries/create_table_map_sprite_frames.sql"))?; conn.execute_batch(include_str!("queries/create_table_map_metadata.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_overlay_modes.sql"))?; + conn.execute_batch(include_str!( + "queries/create_table_map_sprite_sequences.sql" + ))?; + conn.execute_batch(include_str!("queries/create_table_map_object_refs.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_object_metadata.sql"))?; conn.execute_batch(include_str!("queries/create_table_dialogue_paragraphs.sql"))?; conn.execute_batch(include_str!("queries/create_table_party_levels.sql"))?; conn.execute_batch(include_str!("queries/create_table_party_inis.sql"))?; diff --git a/src/map/fogdata.rs b/src/map/fogdata.rs index 3d766d75..b271f55f 100644 --- a/src/map/fogdata.rs +++ b/src/map/fogdata.rs @@ -190,6 +190,30 @@ impl FogData { } } +/// Persists the full fade table to the `fog_factors` SQLite table. +/// +/// Writes exactly [`ROWS`] × [`ROW_LEN`] = 62,976 rows — one per file byte — +/// so the decoded table is a fully lossless 1:1 representation of +/// `fogdata.dat` (no raw blob is needed; `byte[(level-1)*512 + pair]` +/// round-trips exactly). +pub fn save_to_db(conn: &mut rusqlite::Connection, fog: &FogData) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_fog_factor.sql"))?; + for level in 1..=ROWS as u32 { + for pair in 0..ROW_LEN { + stmt.execute(rusqlite::params![ + level as i32, + pair as i32, + fog.factor(level, pair) as i32, + ])?; + } + } + } + tx.commit()?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -275,4 +299,43 @@ mod tests { assert_eq!(row[p], fog.factor(5, p)); } } + + #[test] + fn test_save_to_db_round_trips_fixture() { + let fixture_dir = Path::new("fixtures/Dispel"); + if !fixture_dir.join("ExtraInGame/fogdata.dat").exists() { + eprintln!("Skipping test_save_to_db_round_trips_fixture: fixture not found"); + return; + } + + let fog = FogData::load(fixture_dir).expect("loading fogdata.dat fixture"); + let mut conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); + conn.execute_batch(include_str!("../queries/create_table_fog_factors.sql")) + .expect("creating fog_factors table"); + + save_to_db(&mut conn, &fog).expect("save_to_db"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM fog_factors", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count as usize, EXPECTED_LEN); + + // Known values from the shipped file (see render.rs tests). + let factor_at = |level: i32, pair: i32| -> i32 { + conn.query_row( + "SELECT factor FROM fog_factors WHERE level = ?1 AND pair_index = ?2", + rusqlite::params![level, pair], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(factor_at(1, 0), 2); + assert_eq!(factor_at(65, 256), 31); + + // Sampled rows must equal the in-memory table exactly. + for (k, p) in [(1u32, 0usize), (2, 1), (65, 256), (100, 511), (123, 511)] { + let stored = factor_at(k as i32, p as i32); + assert_eq!(stored as u8, fog.row(k)[p], "level {k} pair {p}"); + } + } } diff --git a/src/map/mod.rs b/src/map/mod.rs index 6dfa7d3a..fb9b78c1 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -109,6 +109,7 @@ pub mod types; pub mod writer; // ── Re-export the entire public surface so external code needs no changes ── +pub use self::fogdata::{FogData, save_to_db as save_fog_to_db}; pub use model::{MapModel, read_map_model}; pub use render::{EntityRenderInfo, ExternalEntities, LayerToggles}; pub use types::{ @@ -134,10 +135,11 @@ use serde::{Deserialize, Serialize}; type IoResult = std::io::Result; use reader::{ - read_events_block, read_overlay_id_table, read_roof_tiles, read_tiles_and_access_block, - skip_tiled_object_refs, sprite_block, sprite_info_block, tiled_objects_block, + read_events_block, read_overlay_id_table, read_roof_tiles, read_tiled_object_refs, + read_tiles_and_access_block, sprite_block, sprite_info_block, tiled_objects_block, }; use render::{MapRenderConfig, render_map}; +use types::TiledObjectMetadata; // -------------------------------------------------------------------------- // MapData – the in-memory representation of a parsed .map file @@ -160,6 +162,14 @@ pub struct MapData { pub tiled_infos: Vec, pub internal_sprites: Vec, pub sprite_blocks: Vec, + /// Image stamp per embedded sprite (6 or 9), parallel to + /// `internal_sprites` — same length, same order. + pub internal_sprite_stamps: Vec, + /// First-block tiled-object ref records `(value0, value1)` in file order. + pub tiled_object_refs: Vec<(i32, i32)>, + /// Per-bundle retained metadata, parallel to `tiled_infos` — same length, + /// same order. + pub tiled_object_metadata: Vec, } /// JSON-serializable representation of map data. @@ -394,12 +404,14 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { let tiled_map_width = map_model.tiled_map_width; let tiled_map_height = map_model.tiled_map_height; - skip_tiled_object_refs(reader)?; + // First-block refs are now read (previously skipped) — see + // `reader::read_tiled_object_refs`. + let tiled_object_refs = read_tiled_object_refs(reader)?; let overlay_modes = read_overlay_id_table(reader)?; - let internal_sprites = sprite_block(reader)?; + let (internal_sprites, internal_sprite_stamps) = sprite_block(reader)?; let sprite_blocks = sprite_info_block(reader, &internal_sprites)?; - let tiled_infos = tiled_objects_block(reader)?; + let (tiled_infos, tiled_object_metadata) = tiled_objects_block(reader)?; // Event and tile blocks live at the end of the file // Calculate expected size for the three end blocks @@ -457,6 +469,9 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { tiled_infos, internal_sprites, sprite_blocks, + internal_sprite_stamps, + tiled_object_refs, + tiled_object_metadata, }) } @@ -670,6 +685,8 @@ pub fn save_to_db( btl_tiles: &data.btl_tiles, collisions: &data.collisions, events: &data.events, + access_ref_words: &data.access_ref_words, + object_ids: &data.object_ids, width: data.model.tiled_map_width, height: data.model.tiled_map_height, })?; @@ -682,6 +699,14 @@ pub fn save_to_db( save_map_metadata(conn, map_id, &data.model)?; + save_map_overlay_modes(conn, map_id, &data.overlay_modes)?; + + save_map_sprite_sequences(conn, map_id, &data.internal_sprites)?; + + save_map_object_refs(conn, map_id, &data.tiled_object_refs)?; + + save_map_object_metadata(conn, map_id, &data.tiled_infos, &data.tiled_object_metadata)?; + Ok(()) } @@ -692,6 +717,8 @@ pub struct SaveMapTilesParams<'a> { pub btl_tiles: &'a HashMap, pub collisions: &'a HashMap, pub events: &'a HashMap, + pub access_ref_words: &'a HashMap, + pub object_ids: &'a HashMap, pub width: i32, pub height: i32, } @@ -703,6 +730,8 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { let btl_tiles = params.btl_tiles; let collisions = params.collisions; let events = params.events; + let access_ref_words = params.access_ref_words; + let object_ids = params.object_ids; let width = params.width; let height = params.height; @@ -727,9 +756,21 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { let collision = collisions.get(&coords).cloned().unwrap_or(false); let event_id = events.get(&coords).map(|e| e.event_id()).unwrap_or(0); - if gtl_id == 0 && btl_id == 0 && !collision && event_id == 0 { - continue; - } + // Dense grid: every (x, y) is written so access/shadow data of + // otherwise "empty" tiles survives the round-trip. + let event_word = events + .get(&coords) + .map(|e| e.word as i64) + .unwrap_or_default(); + let marked = events + .get(&coords) + .map(|e| e.is_tile_marked()) + .unwrap_or_default(); + let object_id = object_ids.get(&coords).copied(); + let raw_ref = access_ref_words.get(&coords).copied().unwrap_or(0); + let shadow_level = ((raw_ref >> 15) & 0x7FFF) as i64; + let light_flags = ((raw_ref >> 30) & 0x3) as i64; + let access_ref_word = raw_ref as i64; stmt.execute(params![ map_id, @@ -739,6 +780,12 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { btl_id, collision, event_id as i32, + event_word, + marked, + object_id, + shadow_level, + light_flags, + access_ref_word, ])?; } } @@ -788,6 +835,10 @@ pub fn save_map_sprites( block.sprite_x, block.sprite_y, block.sprite_id as i32, + block.sprite_bottom_right_y, + block.bbox_left, + block.bbox_top, + block.bbox_right, ])?; } } @@ -847,6 +898,8 @@ pub fn save_map_sprite_frames( frame.height, frame.origin_x, frame.origin_y, + frame.size_bytes, + frame.image_start_position as i64, ])?; } } @@ -874,6 +927,109 @@ pub fn save_map_metadata(conn: &mut Connection, map_id: &str, model: &MapModel) Ok(()) } +/// Persists the overlay-id lookup table: one row per u16 entry, with the +/// low byte decoded as transparency mode and the high byte as draw-enable. +pub fn save_map_overlay_modes( + conn: &mut Connection, + map_id: &str, + overlay_modes: &[u16], +) -> DbResult<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_map_overlay_mode.sql"))?; + for (index, &raw) in overlay_modes.iter().enumerate() { + stmt.execute(params![ + map_id, + index as i32, + raw as i32, + (raw & 0xFF) as i32, + (raw >> 8) as i32, + ])?; + } + } + tx.commit()?; + Ok(()) +} + +/// Persists sequence-level info for each embedded internal sprite +/// (file offsets + frame count). +pub fn save_map_sprite_sequences( + conn: &mut Connection, + map_id: &str, + internal_sprites: &[SequenceInfo], +) -> DbResult<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_map_sprite_sequence.sql"))?; + for (seq_idx, seq) in internal_sprites.iter().enumerate() { + stmt.execute(params![ + map_id, + seq_idx as i32, + seq.sequence_start_position as i64, + seq.sequence_end_position as i64, + seq.frame_count, + ])?; + } + } + tx.commit()?; + Ok(()) +} + +/// Persists the first-block tiled-object ref records in file order. +pub fn save_map_object_refs( + conn: &mut Connection, + map_id: &str, + refs: &[(i32, i32)], +) -> DbResult<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_map_object_ref.sql"))?; + for (ref_index, &(value0, value1)) in refs.iter().enumerate() { + stmt.execute(params![map_id, ref_index as i32, value0, value1])?; + } + } + tx.commit()?; + Ok(()) +} + +/// Persists per-bundle retained metadata from the tiled objects block. +/// +/// Defensive guard: if `metadata.len() != tiled_infos.len()` (should not +/// happen — both come from one parse pass), whatever exists is still written +/// indexed positionally and the export continues. +pub fn save_map_object_metadata( + conn: &mut Connection, + map_id: &str, + _tiled_infos: &[TiledObjectInfo], + metadata: &[TiledObjectMetadata], +) -> DbResult<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare(include_str!("../queries/insert_map_object_metadata.sql"))?; + for (obj_idx, meta) in metadata.iter().enumerate() { + stmt.execute(params![ + map_id, + obj_idx as i32, + meta.metadata_blob, + meta.control_0, + meta.control_1, + meta.control_2, + meta.control_3, + meta.param_0, + meta.param_1, + meta.param_2, + meta.param_3, + meta.param_4, + meta.param_5, + meta.extra_count_a, + meta.extra_count_b, + ])?; + } + } + tx.commit()?; + Ok(()) +} + #[cfg(test)] mod shadow_tests { use super::*; @@ -892,6 +1048,9 @@ mod shadow_tests { tiled_infos: Vec::new(), internal_sprites: Vec::new(), sprite_blocks: Vec::new(), + internal_sprite_stamps: Vec::new(), + tiled_object_refs: Vec::new(), + tiled_object_metadata: Vec::new(), }; // No shadows yet @@ -915,3 +1074,286 @@ mod shadow_tests { #[cfg(test)] mod render_placement_tests; + +#[cfg(test)] +mod db_export_tests { + use super::*; + use rusqlite::Connection; + + const CAT1_MAP: &str = "fixtures/Dispel/Map/cat1.map"; + + fn parse_fixture() -> (MapData, BufReader) { + let file = File::open(CAT1_MAP).expect("cat1.map fixture must exist"); + let mut reader = BufReader::new(file); + let data = read_map_data(&mut reader).expect("parsing cat1.map"); + // Rewind to start so save_map_sprite_frames can re-read pixel data. + reader.seek(SeekFrom::Start(0)).unwrap(); + (data, reader) + } + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory db"); + conn.execute_batch(include_str!("../queries/create_table_map_tiles.sql")) + .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_objects.sql")) + .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_sprites.sql")) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_sprite_frames.sql" + )) + .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_metadata.sql")) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_overlay_modes.sql" + )) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_sprite_sequences.sql" + )) + .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_object_refs.sql")) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_object_metadata.sql" + )) + .unwrap(); + conn + } + + fn count(conn: &Connection, sql: &str) -> i64 { + conn.query_row(sql, [], |row| row.get(0)).unwrap() + } + + #[test] + fn test_db_export_overlay_modes_match_parsed_table() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_overlay_modes"), + data.overlay_modes.len() as i64 + ); + + // Spot-check: decoded lo/hi bytes must match the raw u16. + for idx in [ + 0usize, + data.overlay_modes.len() / 2, + data.overlay_modes.len() - 1, + ] { + let raw = data.overlay_modes[idx]; + let (mode, lo, hi): (i64, i64, i64) = conn + .query_row( + "SELECT mode, transparency_mode, draw_enable FROM map_overlay_modes \ + WHERE overlay_index = ?1", + [idx as i32], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(mode, raw as i64); + assert_eq!(lo, (raw & 0xFF) as i64); + assert_eq!(hi, (raw >> 8) as i64); + } + } + + #[test] + fn test_db_export_tiles_cover_full_grid_with_decoded_fields() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + let w = data.model.tiled_map_width; + let h = data.model.tiled_map_height; + let offset_x = w / 2; + let offset_y = h / 2; + + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_tiles"), + (w as i64) * (h as i64), + "dense grid: every (x, y) must have a row" + ); + + // Sample a deterministic spread of tiles. + for &(sx, sy) in &[ + (0i32, 0i32), + (w / 3, h / 3), + (w - 1, h - 1), + (0, h - 1), + (w - 1, 0), + ] { + let coords = (sx, sy); + let tile_row: [Option; 6] = conn + .query_row( + "SELECT event_word, marked, object_id, shadow_level, light_flags, \ + access_ref_word FROM map_tiles WHERE x = ?1 AND y = ?2", + params![sx - offset_x, sy - offset_y], + |row| { + Ok([ + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + ]) + }, + ) + .unwrap_or([None, None, None, None, None, None]); + // unwrap_or would swallow a missing row; require presence: + if tile_row[0].is_none() && !data.events.contains_key(&coords) { + panic!("missing tile row at {:?}", coords); + } + + let event_word = data.events.get(&coords).map(|e| e.word as i64).unwrap_or(0); + let marked = data + .events + .get(&coords) + .map(|e| e.is_tile_marked()) + .unwrap_or(false); + let raw_ref = data.access_ref_words.get(&coords).copied().unwrap_or(0); + let expected_object = data.object_ids.get(&coords).map(|&v| v as i64); + + assert_eq!( + tile_row[0].unwrap(), + event_word, + "event_word at {:?}", + coords + ); + assert_eq!(tile_row[1].unwrap() != 0, marked, "marked at {:?}", coords); + assert_eq!(tile_row[2], expected_object, "object_id at {:?}", coords); + assert_eq!( + tile_row[3].unwrap(), + ((raw_ref >> 15) & 0x7FFF) as i64, + "shadow_level at {:?}", + coords + ); + assert_eq!( + tile_row[4].unwrap(), + ((raw_ref >> 30) & 0x3) as i64, + "light_flags at {:?}", + coords + ); + assert_eq!( + tile_row[5].unwrap(), + raw_ref as i64, + "access_ref_word at {:?}", + coords + ); + } + } + + #[test] + fn test_db_export_object_id_null_exactly_where_absent() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + // Sampled check: every sampled coordinate's NULL-ness matches the map. + let w = data.model.tiled_map_width; + let h = data.model.tiled_map_height; + for &(sx, sy) in &[(0i32, 0i32), (w / 2, h / 2), (w - 1, h - 1)] { + let coords = (sx, sy); + let stored: Option> = conn + .query_row( + "SELECT object_id FROM map_tiles WHERE x = ?1 AND y = ?2", + params![sx - w / 2, sy - h / 2], + |row| row.get::<_, Option>(0).map(Some), + ) + .ok() + .flatten(); + let stored = stored.expect("tile row must exist"); + assert_eq!( + stored.as_ref().copied(), + data.object_ids.get(&coords).map(|&v| v as i64), + "object_id NULL pattern at {:?}", + coords + ); + } + } + + #[test] + fn test_db_export_sprites_carry_bottom_right_y_and_bbox() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + for sprite_idx in 0..data.sprite_blocks.len().min(5) { + let block = &data.sprite_blocks[sprite_idx]; + let (bry, l, t, r): (i64, Option, Option, Option) = conn + .query_row( + "SELECT bottom_right_y, bbox_left, bbox_top, bbox_right FROM map_sprites \ + WHERE sprite_id = ?1", + [sprite_idx as i32], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(bry, block.sprite_bottom_right_y as i64); + assert_eq!(l, Some(block.bbox_left as i64)); + assert_eq!(t, Some(block.bbox_top as i64)); + assert_eq!(r, Some(block.bbox_right as i64)); + } + } + + #[test] + fn test_db_export_sprite_sequences_and_frames_match_parsed_data() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_sprite_sequences"), + data.internal_sprites.len() as i64 + ); + + 'outer: for (seq_idx, seq) in data.internal_sprites.iter().enumerate() { + for (frame_idx, frame) in seq.frame_infos.iter().enumerate() { + let (size_bytes, start_pos, png_len): (i64, i64, i64) = conn + .query_row( + "SELECT size_bytes, image_start_position, LENGTH(png_blob) \ + FROM map_sprite_frames WHERE internal_sprite_id = ?1 AND frame_index = ?2", + params![seq_idx as i32, frame_idx as i32], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(size_bytes, frame.size_bytes); + assert_eq!(start_pos, frame.image_start_position as i64); + assert!(png_len > 0, "png_blob must be non-empty"); + if seq_idx >= 2 { + break 'outer; // sample enough sequences only + } + } + } + } + + #[test] + fn test_db_export_object_refs_and_metadata_match_parsed_data() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_object_refs"), + data.tiled_object_refs.len() as i64 + ); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_object_metadata"), + data.tiled_infos.len() as i64, + "one metadata row per tiled object bundle" + ); + } + + #[test] + fn test_db_export_internal_sprite_stamps_are_known_values() { + let (data, _reader) = parse_fixture(); + assert_eq!( + data.internal_sprite_stamps.len(), + data.internal_sprites.len(), + "stamps parallel to internal_sprites" + ); + for stamp in &data.internal_sprite_stamps { + assert!(*stamp == 6 || *stamp == 9, "unexpected stamp {stamp}"); + } + } +} diff --git a/src/map/reader.rs b/src/map/reader.rs index 56ab1c0e..0db7b8a4 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -16,12 +16,12 @@ use byteorder::{LittleEndian, ReadBytesExt}; use std::collections::HashMap; use std::fs::File; -use std::io::{BufReader, Result, Seek, SeekFrom}; +use std::io::{BufReader, Read, Result, Seek, SeekFrom}; use crate::sprite; use crate::sprite::SequenceInfo; -use super::types::{Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo}; +use super::types::{Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo, TiledObjectMetadata}; // -------------------------------------------------------------------------- // Skipped blocks (parsed by the format but not persisted in MapData) @@ -38,11 +38,35 @@ use super::types::{Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo}; /// lands exactly on the overlay table's size field, while `count*8` lands on /// `0x01010101` garbage. The previous `multiplier*size*4` skip only worked by /// coincidence (`8 + 2*count*4 == 16 + (count-1)*8`). -pub fn skip_tiled_object_refs(reader: &mut BufReader) -> Result<()> { +/// Reads (instead of skipping) the tiled-object ref records: `count` (i32) +/// followed by `(count - 1)` records of 2 × i32 each (`value1`, `value2`). +/// +/// `value2` is a linear tile index (`y * stride + x`) into the three end +/// grids — tiled-object rendering and access checks resolve tiles +/// through it. +/// +/// Verified against cat1/cat3/dun01/map1/catp fixtures: reading `(count-1)` +/// pairs lands exactly on the overlay table's size field, while `count*8` +/// lands on `0x01010101` garbage. The previous `multiplier*size*4` skip only +/// worked by coincidence (`8 + 2*count*4 == 16 + (count-1)*8`). +/// +/// A count below 1 cannot be honored (the format has no negative-record +/// semantics) and yields a clear error instead of a silent negative seek. +pub fn read_tiled_object_refs(reader: &mut BufReader) -> Result> { let count = reader.read_i32::()?; - let skip: i64 = ((count - 1) * 8).into(); - reader.seek(SeekFrom::Current(skip))?; - Ok(()) + if count < 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-object ref count {count}"), + )); + } + let mut refs = Vec::with_capacity((count - 1).max(0) as usize); + for _ in 0..(count - 1) { + let value0 = reader.read_i32::()?; + let value1 = reader.read_i32::()?; + refs.push((value0, value1)); + } + Ok(refs) } /// Overlay-id lookup table: `size` (i32) followed by `size` u16 entries. @@ -67,9 +91,12 @@ pub fn read_overlay_id_table(reader: &mut BufReader) -> Result> { // Sprite block – embedded sprites stored inside the map file // -------------------------------------------------------------------------- -pub fn sprite_block(reader: &mut BufReader) -> Result> { +/// Returns `(sequences, image_stamps)` — one stamp per sequence, same order +/// (stamps are always 6 or 9; anything else is a parse error). +pub fn sprite_block(reader: &mut BufReader) -> Result<(Vec, Vec)> { let sprite_count = reader.read_i32::()?; let mut sprites = vec![]; + let mut stamps = Vec::with_capacity(sprite_count.unsigned_abs() as usize); for _ in 0..sprite_count { let image_stamp = reader.read_i32::()?; let image_offset: i32 = if image_stamp == 6 { @@ -82,6 +109,7 @@ pub fn sprite_block(reader: &mut BufReader) -> Result> { format!("Unexpected image-stamp {image_stamp}"), )); }; + stamps.push(image_stamp); reader.seek(SeekFrom::Current(264))?; @@ -93,7 +121,7 @@ pub fn sprite_block(reader: &mut BufReader) -> Result> { let image_offset: i64 = image_offset.into(); reader.seek(SeekFrom::Current(image_offset))?; } - Ok(sprites) + Ok((sprites, stamps)) } // -------------------------------------------------------------------------- @@ -112,9 +140,9 @@ pub fn sprite_info_block( // Frame-0 record (24 bytes): bbox {left, top, right, bottom} in map // pixels + duplicated anchor. left/top are re-read as x/y below; // right is unused (== left + frame width). - let _bbox_left = reader.read_i32::()?; - let _bbox_top = reader.read_i32::()?; - let _bbox_right = reader.read_i32::()?; + let bbox_left = reader.read_i32::()?; + let bbox_top = reader.read_i32::()?; + let bbox_right = reader.read_i32::()?; let sprite_bottom_right_y = reader.read_i32::()?; let sprite_x = reader.read_i32::()?; let sprite_y = reader.read_i32::()?; @@ -130,6 +158,9 @@ pub fn sprite_info_block( sprite_x, sprite_y, sprite_bottom_right_y, + bbox_left, + bbox_top, + bbox_right, }); } Ok(info) @@ -146,36 +177,44 @@ pub fn sprite_info_block( // empirically fitted layout that round-trips all known maps. // -------------------------------------------------------------------------- -pub fn tiled_objects_block(reader: &mut BufReader) -> Result> { +/// Returns `(infos, metadata)` — one [`TiledObjectMetadata`] per bundle, same +/// order as the returned [`TiledObjectInfo`]s. +pub fn tiled_objects_block( + reader: &mut BufReader, +) -> Result<(Vec, Vec)> { let bundles_count = reader.read_i32::()?; // Sub-record count inside the bundle: sub-records of {stamp, 260-byte // metadata, items} follow at this point. let _sub_record_count = reader.read_i32::()?; let mut infos: Vec = Vec::with_capacity(bundles_count.unsigned_abs() as usize); + let mut metadata: Vec = + Vec::with_capacity(bundles_count.unsigned_abs() as usize); for _ in 0..bundles_count { // 264 bytes: stamp i32 + metadata block (building name/definition). - reader.seek(SeekFrom::Current(264))?; + // Read verbatim (stamp included) so the DB export is lossless. + let mut metadata_blob = vec![0u8; 264]; + reader.read_exact(&mut metadata_blob)?; // Control words — always observed as (8, 0, 1, 0) in known maps. - let _control_0 = reader.read_i32::()?; - let _control_1 = reader.read_i32::()?; - let _control_2 = reader.read_i32::()?; - let _control_3 = reader.read_i32::()?; + let control_0 = reader.read_i32::()?; + let control_1 = reader.read_i32::()?; + let control_2 = reader.read_i32::()?; + let control_3 = reader.read_i32::()?; // Unmapped parameters preceding the stack anchor. - let _param_0 = reader.read_i32::()?; - let _param_1 = reader.read_i32::()?; - let _param_2 = reader.read_i32::()?; - let _param_3 = reader.read_i32::()?; + let param_0 = reader.read_i32::()?; + let param_1 = reader.read_i32::()?; + let param_2 = reader.read_i32::()?; + let param_3 = reader.read_i32::()?; // Anchor of the building stack in map-local pixel coordinates. let x = reader.read_i32::()?; let y = reader.read_i32::()?; // Unmapped parameters following the anchor. - let _param_4 = reader.read_i32::()?; - let _param_5 = reader.read_i32::()?; + let param_4 = reader.read_i32::()?; + let param_5 = reader.read_i32::()?; // Trailing counts. Only `tile_stack_len` BTL tile ids follow here; // the other two counts gate data covered by the skip below. @@ -190,6 +229,21 @@ pub fn tiled_objects_block(reader: &mut BufReader) -> Result) -> Result Result<()> { } // ── Pre-load external entities (if game path given) ────────────────── - let external = game_path.and_then(|gp| { - match collect_external_entities(map_id, gp, &data.model) { - Ok(entities) => Some(entities), - Err(e) => { - eprintln!("WARNING: no external entities rendered for map '{map_id}': {e}"); - None - } - } - }); + let external = + game_path.and_then( + |gp| match collect_external_entities(map_id, gp, &data.model) { + Ok(entities) => Some(entities), + Err(e) => { + eprintln!("WARNING: no external entities rendered for map '{map_id}': {e}"); + None + } + }, + ); // ── Pass 2: Interleaved objects + entities ─────────────────────────── // All depth-relevant items (buildings, internal sprites, monsters, NPCs, @@ -280,8 +281,9 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { ItemKind::Sprite(i) => { let block = &data.sprite_blocks[*i]; let sequence = &data.internal_sprites[block.sprite_id]; - let f = - toggles.sprite_frame.map_or(0, |f| f.min(sequence.frame_infos.len() - 1)); + let f = toggles + .sprite_frame + .map_or(0, |f| f.min(sequence.frame_infos.len() - 1)); let sprite = &sequence.frame_infos[f]; let dest_x = block.sprite_x; let dest_y = block.sprite_y; @@ -343,7 +345,13 @@ pub fn render_map(config: MapRenderConfig) -> Result<()> { if toggles.show_shadows && let Some(fog) = prepare_shadow_pass(game_path, map_id) { - plot_shadows(&mut imgbuf, &data.model, &data.access_ref_words, &fog, lights); + plot_shadows( + &mut imgbuf, + &data.model, + &data.access_ref_words, + &fog, + lights, + ); } // ── Pass 4: Overlays ───────────────────────────────────────────────── @@ -1759,7 +1767,10 @@ fn dynamic_entity_light_max_wins_over_static() { "dimmer light must never reduce a brighter static level" ); // Sanity: the tile actually received the bright static fade. - assert_eq!(base_img.get_pixel((px + 32) as u32, (py + 16) as u32)[0], (200 * 31 / 32) as u8); + assert_eq!( + base_img.get_pixel((px + 32) as u32, (py + 16) as u32)[0], + (200 * 31 / 32) as u8 + ); } #[test] diff --git a/src/map/types.rs b/src/map/types.rs index 77adc58e..85bc4fc4 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -93,6 +93,38 @@ pub struct SpriteInfoBlock { /// Bottom edge of the sprite's bounding box (= top + frame height). /// Y-sort key for interlaced rendering. pub sprite_bottom_right_y: i32, + /// Left edge of the frame-0 bounding box in map pixels (raw disk value; + /// duplicates `sprite_x` in known maps). + pub bbox_left: i32, + /// Top edge of the frame-0 bounding box in map pixels (duplicates `sprite_y`). + pub bbox_top: i32, + /// Right edge of the frame-0 bounding box (== left + frame width). + pub bbox_right: i32, +} + +/// Retained per-bundle metadata from the tiled objects block, preserved so the +/// DB export is lossless. The 264-byte bundle header is stored verbatim +/// (its first i32 is a stamp); control words, unmapped parameters and trailing +/// counts are captured as named fields. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TiledObjectMetadata { + /// The raw 264-byte bundle header, first i32 stamp included verbatim. + pub metadata_blob: Vec, + /// Control words — always observed as `(8, 0, 1, 0)` in known maps. + pub control_0: i32, + pub control_1: i32, + pub control_2: i32, + pub control_3: i32, + /// Unmapped parameters preceding/following the stack anchor. + pub param_0: i32, + pub param_1: i32, + pub param_2: i32, + pub param_3: i32, + pub param_4: i32, + pub param_5: i32, + /// Trailing counts gating data covered by the post-stack skip. + pub extra_count_a: i32, + pub extra_count_b: i32, } /// A building/object made up of stacked BTL tileset tiles. diff --git a/src/map/writer.rs b/src/map/writer.rs index cd808487..402de6d1 100644 --- a/src/map/writer.rs +++ b/src/map/writer.rs @@ -199,6 +199,9 @@ mod tests { tiled_infos: Vec::new(), internal_sprites: Vec::new(), sprite_blocks: Vec::new(), + internal_sprite_stamps: Vec::new(), + tiled_object_refs: Vec::new(), + tiled_object_metadata: Vec::new(), }; let result = write_map_to_path(&tmp, &data); diff --git a/src/queries/create_table_fog_factors.sql b/src/queries/create_table_fog_factors.sql new file mode 100644 index 00000000..f9558845 --- /dev/null +++ b/src/queries/create_table_fog_factors.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS fog_factors ( + level INTEGER NOT NULL, + pair_index INTEGER NOT NULL, + factor INTEGER NOT NULL, + PRIMARY KEY (level, pair_index) +); +CREATE INDEX IF NOT EXISTS idx_fog_factors_level ON fog_factors(level); diff --git a/src/queries/create_table_map_object_metadata.sql b/src/queries/create_table_map_object_metadata.sql new file mode 100644 index 00000000..141faa53 --- /dev/null +++ b/src/queries/create_table_map_object_metadata.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS map_object_metadata ( + map_id TEXT NOT NULL, + object_index INTEGER NOT NULL, + metadata_blob BLOB NOT NULL, + control_0 INTEGER NOT NULL, + control_1 INTEGER NOT NULL, + control_2 INTEGER NOT NULL, + control_3 INTEGER NOT NULL, + param_0 INTEGER NOT NULL, + param_1 INTEGER NOT NULL, + param_2 INTEGER NOT NULL, + param_3 INTEGER NOT NULL, + param_4 INTEGER NOT NULL, + param_5 INTEGER NOT NULL, + extra_count_a INTEGER NOT NULL, + extra_count_b INTEGER NOT NULL, + PRIMARY KEY (map_id, object_index) +); diff --git a/src/queries/create_table_map_object_refs.sql b/src/queries/create_table_map_object_refs.sql new file mode 100644 index 00000000..54b5a372 --- /dev/null +++ b/src/queries/create_table_map_object_refs.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS map_object_refs ( + map_id TEXT NOT NULL, + ref_index INTEGER NOT NULL, + value0 INTEGER NOT NULL, + value1 INTEGER NOT NULL, + PRIMARY KEY (map_id, ref_index) +); diff --git a/src/queries/create_table_map_overlay_modes.sql b/src/queries/create_table_map_overlay_modes.sql new file mode 100644 index 00000000..6f0d17fc --- /dev/null +++ b/src/queries/create_table_map_overlay_modes.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS map_overlay_modes ( + map_id TEXT NOT NULL, + overlay_index INTEGER NOT NULL, + mode INTEGER NOT NULL, + transparency_mode INTEGER NOT NULL, + draw_enable INTEGER NOT NULL, + PRIMARY KEY (map_id, overlay_index) +); diff --git a/src/queries/create_table_map_sprite_frames.sql b/src/queries/create_table_map_sprite_frames.sql index eb006569..035ce7f1 100644 --- a/src/queries/create_table_map_sprite_frames.sql +++ b/src/queries/create_table_map_sprite_frames.sql @@ -7,5 +7,7 @@ CREATE TABLE IF NOT EXISTS map_sprite_frames ( height INTEGER NOT NULL, origin_x INTEGER NOT NULL, origin_y INTEGER NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + image_start_position INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (map_id, internal_sprite_id, frame_index) ); diff --git a/src/queries/create_table_map_sprite_sequences.sql b/src/queries/create_table_map_sprite_sequences.sql new file mode 100644 index 00000000..a74312c0 --- /dev/null +++ b/src/queries/create_table_map_sprite_sequences.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS map_sprite_sequences ( + map_id TEXT NOT NULL, + sequence_index INTEGER NOT NULL, + start_position INTEGER NOT NULL, + end_position INTEGER NOT NULL, + frame_count INTEGER NOT NULL, + PRIMARY KEY (map_id, sequence_index) +); diff --git a/src/queries/create_table_map_sprites.sql b/src/queries/create_table_map_sprites.sql index 4fb08425..15b19be7 100644 --- a/src/queries/create_table_map_sprites.sql +++ b/src/queries/create_table_map_sprites.sql @@ -4,6 +4,10 @@ CREATE TABLE IF NOT EXISTS map_sprites ( x INTEGER, y INTEGER, internal_sprite_id INTEGER, + bottom_right_y INTEGER NOT NULL DEFAULT 0, + bbox_left INTEGER, + bbox_top INTEGER, + bbox_right INTEGER, PRIMARY KEY (map_id, sprite_id) ); CREATE INDEX IF NOT EXISTS idx_map_sprites_map_id ON map_sprites(map_id); diff --git a/src/queries/create_table_map_tiles.sql b/src/queries/create_table_map_tiles.sql index d74ffd66..4685b04c 100644 --- a/src/queries/create_table_map_tiles.sql +++ b/src/queries/create_table_map_tiles.sql @@ -6,6 +6,12 @@ CREATE TABLE IF NOT EXISTS map_tiles ( btl_tile_id INTEGER, collision BOOLEAN, event_id INTEGER, + event_word INTEGER NOT NULL DEFAULT 0, + marked BOOLEAN NOT NULL DEFAULT FALSE, + object_id INTEGER, + shadow_level INTEGER NOT NULL DEFAULT 0, + light_flags INTEGER NOT NULL DEFAULT 0, + access_ref_word INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (map_id, x, y) ); CREATE INDEX IF NOT EXISTS idx_map_tiles_coords ON map_tiles(map_id, x, y); diff --git a/src/queries/insert_fog_factor.sql b/src/queries/insert_fog_factor.sql new file mode 100644 index 00000000..3e2a4dce --- /dev/null +++ b/src/queries/insert_fog_factor.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO fog_factors (level, pair_index, factor) +VALUES (?1, ?2, ?3); diff --git a/src/queries/insert_map_object_metadata.sql b/src/queries/insert_map_object_metadata.sql new file mode 100644 index 00000000..49bbc5d5 --- /dev/null +++ b/src/queries/insert_map_object_metadata.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_object_metadata (map_id, object_index, metadata_blob, control_0, control_1, control_2, control_3, param_0, param_1, param_2, param_3, param_4, param_5, extra_count_a, extra_count_b) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15); diff --git a/src/queries/insert_map_object_ref.sql b/src/queries/insert_map_object_ref.sql new file mode 100644 index 00000000..ad6b2f2e --- /dev/null +++ b/src/queries/insert_map_object_ref.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_object_refs (map_id, ref_index, value0, value1) +VALUES (?1, ?2, ?3, ?4); diff --git a/src/queries/insert_map_overlay_mode.sql b/src/queries/insert_map_overlay_mode.sql new file mode 100644 index 00000000..cff6dcf0 --- /dev/null +++ b/src/queries/insert_map_overlay_mode.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_overlay_modes (map_id, overlay_index, mode, transparency_mode, draw_enable) +VALUES (?1, ?2, ?3, ?4, ?5); diff --git a/src/queries/insert_map_sprite.sql b/src/queries/insert_map_sprite.sql index feaa5130..f6b24f16 100644 --- a/src/queries/insert_map_sprite.sql +++ b/src/queries/insert_map_sprite.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_sprites (map_id, sprite_id, x, y, internal_sprite_id) -VALUES (?, ?, ?, ?, ?); +INSERT OR REPLACE INTO map_sprites (map_id, sprite_id, x, y, internal_sprite_id, bottom_right_y, bbox_left, bbox_top, bbox_right) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9); diff --git a/src/queries/insert_map_sprite_frame.sql b/src/queries/insert_map_sprite_frame.sql index f3e415db..b707c02f 100644 --- a/src/queries/insert_map_sprite_frame.sql +++ b/src/queries/insert_map_sprite_frame.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_sprite_frames (map_id, internal_sprite_id, frame_index, png_blob, width, height, origin_x, origin_y) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); +INSERT OR REPLACE INTO map_sprite_frames (map_id, internal_sprite_id, frame_index, png_blob, width, height, origin_x, origin_y, size_bytes, image_start_position) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10); diff --git a/src/queries/insert_map_sprite_sequence.sql b/src/queries/insert_map_sprite_sequence.sql new file mode 100644 index 00000000..ae2b16c1 --- /dev/null +++ b/src/queries/insert_map_sprite_sequence.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_sprite_sequences (map_id, sequence_index, start_position, end_position, frame_count) +VALUES (?1, ?2, ?3, ?4, ?5); diff --git a/src/queries/insert_map_tile.sql b/src/queries/insert_map_tile.sql index 0b7ca035..8ef16a65 100644 --- a/src/queries/insert_map_tile.sql +++ b/src/queries/insert_map_tile.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id) -VALUES (?, ?, ?, ?, ?, ?, ?); +INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id, event_word, marked, object_id, shadow_level, light_flags, access_ref_word) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13); diff --git a/src/references/map_ini.rs b/src/references/map_ini.rs index 66ab18cd..3795d860 100644 --- a/src/references/map_ini.rs +++ b/src/references/map_ini.rs @@ -103,14 +103,11 @@ pub fn save_map_inis(conn: &mut Connection, map_inis: &[MapIni]) -> Result<()> { { let mut stmt = tx.prepare(include_str!("../queries/insert_map_ini.sql"))?; for map_ini in map_inis { - let camera_event = if map_ini.event_id_on_camera_move == 0 { - None - } else { - Some(map_ini.event_id_on_camera_move) - }; + // Stored verbatim: 0 means "no camera-move event" but is real + // file data, so it must survive the round-trip (no NULLing). stmt.execute(params![ map_ini.id, - camera_event, + map_ini.event_id_on_camera_move, map_ini.start_pos_x, map_ini.start_pos_y, map_ini.map_id, @@ -145,6 +142,33 @@ mod tests { assert!(maps[0].npc_filename.is_none()); } + #[test] + fn test_save_map_inis_stores_camera_event_verbatim() { + // 0 means "no camera-move event" but must be stored as 0, not NULL. + let data = b"1,0,5,10,2,null,null,null,3\n2,1005,3,8,5,a.ref,b.ref,c.ref,7\n"; + let mut c = Cursor::new(data.as_ref()); + let maps = MapIni::parse(&mut c, data.len() as u64).unwrap(); + + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_inis.sql")) + .unwrap(); + save_map_inis(&mut conn, &maps).unwrap(); + + for (id, expected) in [(1, 0), (2, 1005)] { + let stored: i32 = conn + .query_row( + "SELECT event_id_on_camera_move FROM map_inis WHERE id = ?1", + [id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stored, expected, + "event_id_on_camera_move for id {id} must be stored verbatim" + ); + } + } + #[test] fn parse_entry_with_filenames() { let data = b"2,100,3,8,5,mon.ref,npc.ref,ext.ref,7\n"; From adc7488078d12447e939586108e2640f4f8de489 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:41:35 +0200 Subject: [PATCH 32/49] Export trailing data in the tilesets --- src/map/mod.rs | 2 ++ src/map/reader.rs | 22 +++++++++++++++---- src/map/types.rs | 7 ++++++ .../create_table_map_object_metadata.sql | 2 ++ src/queries/insert_map_object_metadata.sql | 4 ++-- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/map/mod.rs b/src/map/mod.rs index fb9b78c1..ce19f351 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1023,6 +1023,8 @@ pub fn save_map_object_metadata( meta.param_5, meta.extra_count_a, meta.extra_count_b, + meta.trailing_fixed, + meta.trailing_variable, ])?; } } diff --git a/src/map/reader.rs b/src/map/reader.rs index 0db7b8a4..379cafbb 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -229,6 +229,22 @@ pub fn tiled_objects_block( } infos.push(TiledObjectInfo { ids, x, y }); + + // Fixed 84-byte trailer after the tile stack (read, not skipped). + let mut trailing_fixed = vec![0u8; 84]; + reader.read_exact(&mut trailing_fixed)?; + + // Variable trailer sized from the three counts. + let var_len = (extra_count_a + extra_count_b + tile_stack_len) * 4; + if var_len < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-object variable trailer length {var_len}"), + )); + } + let mut trailing_variable = vec![0u8; var_len as usize]; + reader.read_exact(&mut trailing_variable)?; + metadata.push(TiledObjectMetadata { metadata_blob, control_0, @@ -243,11 +259,9 @@ pub fn tiled_objects_block( param_5, extra_count_a, extra_count_b, + trailing_fixed, + trailing_variable, }); - - reader.seek(SeekFrom::Current(84))?; - let skip: i64 = ((extra_count_a + extra_count_b + tile_stack_len) * 4).into(); - reader.seek(SeekFrom::Current(skip))?; } // Align past the bundle-end sentinel diff --git a/src/map/types.rs b/src/map/types.rs index 85bc4fc4..df716e75 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -125,6 +125,13 @@ pub struct TiledObjectMetadata { /// Trailing counts gating data covered by the post-stack skip. pub extra_count_a: i32, pub extra_count_b: i32, + /// The fixed 84-byte trailer after the tile stack (unmapped; retained + /// verbatim for lossless DB export). + pub trailing_fixed: Vec, + /// The variable trailer of `(extra_count_a + extra_count_b + + /// tile_stack_len) * 4` bytes after the fixed trailer (unmapped; retained + /// verbatim). + pub trailing_variable: Vec, } /// A building/object made up of stacked BTL tileset tiles. diff --git a/src/queries/create_table_map_object_metadata.sql b/src/queries/create_table_map_object_metadata.sql index 141faa53..4ddf91ad 100644 --- a/src/queries/create_table_map_object_metadata.sql +++ b/src/queries/create_table_map_object_metadata.sql @@ -14,5 +14,7 @@ CREATE TABLE IF NOT EXISTS map_object_metadata ( param_5 INTEGER NOT NULL, extra_count_a INTEGER NOT NULL, extra_count_b INTEGER NOT NULL, + trailing_fixed BLOB NOT NULL, + trailing_variable BLOB NOT NULL, PRIMARY KEY (map_id, object_index) ); diff --git a/src/queries/insert_map_object_metadata.sql b/src/queries/insert_map_object_metadata.sql index 49bbc5d5..da1e4ec1 100644 --- a/src/queries/insert_map_object_metadata.sql +++ b/src/queries/insert_map_object_metadata.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_object_metadata (map_id, object_index, metadata_blob, control_0, control_1, control_2, control_3, param_0, param_1, param_2, param_3, param_4, param_5, extra_count_a, extra_count_b) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15); +INSERT OR REPLACE INTO map_object_metadata (map_id, object_index, metadata_blob, control_0, control_1, control_2, control_3, param_0, param_1, param_2, param_3, param_4, param_5, extra_count_a, extra_count_b, trailing_fixed, trailing_variable) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17); From 412a8dee167fad29deba5de4cc3f5b3e6116d060 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:41:56 +0200 Subject: [PATCH 33/49] Rename column name to "id" --- src/queries/create_table_edit_items.sql | 2 +- src/queries/create_table_monster_refs.sql | 2 +- src/queries/create_table_npc_refs.sql | 2 +- src/queries/create_table_stores.sql | 4 ++-- src/queries/insert_edit_item.sql | 2 +- src/queries/insert_monster_ref.sql | 2 +- src/queries/insert_npc_ref.sql | 2 +- src/queries/insert_store.sql | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/queries/create_table_edit_items.sql b/src/queries/create_table_edit_items.sql index 4013c113..52b47dc8 100644 --- a/src/queries/create_table_edit_items.sql +++ b/src/queries/create_table_edit_items.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS edit_items ( - "index" INTEGER, + id INTEGER, name TEXT, description TEXT, base_price INTEGER, diff --git a/src/queries/create_table_monster_refs.sql b/src/queries/create_table_monster_refs.sql index a04095e2..d7bcf2b4 100644 --- a/src/queries/create_table_monster_refs.sql +++ b/src/queries/create_table_monster_refs.sql @@ -1,7 +1,7 @@ CREATE TABLE monster_refs ( file_id INTEGER NOT NULL, - "index" INTEGER, + id INTEGER, placement_id INTEGER, monster_db_id INTEGER, map_x INTEGER, diff --git a/src/queries/create_table_npc_refs.sql b/src/queries/create_table_npc_refs.sql index 599aa05c..080bc9ee 100644 --- a/src/queries/create_table_npc_refs.sql +++ b/src/queries/create_table_npc_refs.sql @@ -1,7 +1,7 @@ CREATE TABLE npc_refs ( file_id INTEGER NOT NULL, - "index" INTEGER, + id INTEGER, file_record_id INTEGER, npc_ini_id INTEGER, name TEXT, diff --git a/src/queries/create_table_stores.sql b/src/queries/create_table_stores.sql index 6b8fa3df..7f83b636 100644 --- a/src/queries/create_table_stores.sql +++ b/src/queries/create_table_stores.sql @@ -1,10 +1,10 @@ CREATE TABLE IF NOT EXISTS stores ( - "index" INTEGER PRIMARY KEY, + id INTEGER PRIMARY KEY, store_name TEXT, inn_night_cost INTEGER, price_modifier INTEGER, invitation TEXT, haggle_success TEXT, haggle_fail TEXT -) \ No newline at end of file +) diff --git a/src/queries/insert_edit_item.sql b/src/queries/insert_edit_item.sql index 162b5325..90ea3bdb 100644 --- a/src/queries/insert_edit_item.sql +++ b/src/queries/insert_edit_item.sql @@ -1,4 +1,4 @@ -INSERT OR REPLACE INTO edit_items("index", +INSERT OR REPLACE INTO edit_items(id, name, description, base_price, diff --git a/src/queries/insert_monster_ref.sql b/src/queries/insert_monster_ref.sql index e8fa0d8e..3762b81c 100644 --- a/src/queries/insert_monster_ref.sql +++ b/src/queries/insert_monster_ref.sql @@ -1,5 +1,5 @@ INSERT OR REPLACE INTO monster_refs(file_id, - "index", + id, placement_id, monster_db_id, map_x, diff --git a/src/queries/insert_npc_ref.sql b/src/queries/insert_npc_ref.sql index c5371c67..7c718936 100644 --- a/src/queries/insert_npc_ref.sql +++ b/src/queries/insert_npc_ref.sql @@ -1,5 +1,5 @@ INSERT OR REPLACE INTO npc_refs(file_id, - "index", + id, file_record_id, npc_ini_id, name, diff --git a/src/queries/insert_store.sql b/src/queries/insert_store.sql index d0dd2512..70dee969 100644 --- a/src/queries/insert_store.sql +++ b/src/queries/insert_store.sql @@ -1,4 +1,4 @@ -INSERT OR REPLACE INTO stores("index", +INSERT OR REPLACE INTO stores(id, store_name, inn_night_cost, price_modifier, @@ -11,4 +11,4 @@ VALUES (?1, ?4, ?5, ?6, - ?7) \ No newline at end of file + ?7) From 5f189fe3207795449d4865322b556240d41c8bb2 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:50:36 +0200 Subject: [PATCH 34/49] Get rid of the shaky graphic in sprite exports --- src/sprite.rs | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/sprite.rs b/src/sprite.rs index fa71e230..80fedf26 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -417,27 +417,40 @@ pub fn save_sequence_anim( println!("x:{rect_x} y:{rect_y} w:{rect_w} h:{rect_h}"); + let imgbuf = render_sequence_animation(reader, frames, rect_x, rect_y, rect_w, rect_h)?; + + imgbuf + .save(format!("image_{:?}.png", sequence_counter)) + .unwrap(); + + Ok(()) +} + +/// Renders every frame of a sequence into a horizontal animation atlas. +/// +/// Each atlas cell uses the sequence's shared bounding box, keeping the +/// frame anchor at the same position across the animation. +fn render_sequence_animation( + reader: &mut BufReader, + frames: &[ImageInfo], + rect_x: i32, + rect_y: i32, + rect_w: u32, + rect_h: u32, +) -> Result { let atlas_w = rect_w * (frames.len() as u32); let mut imgbuf = RgbaImage::new(atlas_w, rect_h); - let mut offset_x: u32 = 0; for (i, frame) in frames.iter().enumerate() { - let (_, offset_y) = compute_frame_offset(frames, i, rect_x, rect_y); - - let frame_rgba = render_frame_to_rgba(reader, frame, rect_w, rect_h, 0, offset_y)?; + let (offset_x, offset_y) = compute_frame_offset(frames, i, rect_x, rect_y); + let frame_rgba = render_frame_to_rgba(reader, frame, rect_w, rect_h, offset_x, offset_y)?; for (px, py, pixel) in frame_rgba.enumerate_pixels() { - imgbuf.put_pixel(px + offset_x, py, *pixel); + imgbuf.put_pixel(px + (i as u32 * rect_w), py, *pixel); } - - offset_x += rect_w; } - imgbuf - .save(format!("image_{:?}.png", sequence_counter)) - .unwrap(); - - Ok(()) + Ok(imgbuf) } pub fn save_sequence( From 0fcc5d7845d5d2f304985ae894aa7f79ba65d4c2 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:06:09 +0200 Subject: [PATCH 35/49] Recognize the NPC role type (if it is a dialog or a shop) --- dispel-gui/src/editors/npc_ref/component.rs | 4 +- docs/files/NpcInGame/NpcMapFiles.ref.md | 2 +- src/queries/create_table_npc_refs.sql | 2 +- src/queries/insert_npc_ref.sql | 2 +- src/references/enums.rs | 76 +++++++++++++++++++++ src/references/npc_ref.rs | 37 ++++++++-- 6 files changed, 112 insertions(+), 11 deletions(-) diff --git a/dispel-gui/src/editors/npc_ref/component.rs b/dispel-gui/src/editors/npc_ref/component.rs index 9d017299..523a8460 100644 --- a/dispel-gui/src/editors/npc_ref/component.rs +++ b/dispel-gui/src/editors/npc_ref/component.rs @@ -1,7 +1,7 @@ use crate::components::editable::EditableRecord; use dispel_core::NPC; use dispel_core::references::enums::{ - BooleanFlag, NpcInteractionMode, NpcLookingDirection, NpcMovementMode, + BooleanFlag, NpcInteractionMode, NpcLookingDirection, NpcMovementMode, NpcRoleResult, }; use crate::editable_record_fields; @@ -11,7 +11,7 @@ editable_record_fields!(NPC, { { npc_ini_id = Integer / "NPC ID:" }, { name = String / "Name:" }, { role_description = String / "Role Description:" }, - { party_member_slot = Integer / "Party Member Slot:" }, + { role_result = DispEnum(NpcRoleResult, ["NormalDialogue", "PartyMemberDialogue1", "PartyMemberDialogue2", "PartyMemberDialogue3", "PartyMemberDialogue4", "PartyMemberDialogue5", "PartyMemberDialogue6", "PartyMemberDialogue7", "PartyMemberDialogue8", "WeaponShop", "HealMiscShop", "EditItemShop", "Inn"]) / "Role Result:" }, { show_on_event = Integer / "Show on Event:" }, { movement_mode = DispEnum(NpcMovementMode, ["Static", "Waypoints", "RandomInActivationRect"]) / "Movement Mode:" }, { goto1_filled = Enum(BooleanFlag, ["False", "True"]) / "Waypoint 1 Filled:" }, diff --git a/docs/files/NpcInGame/NpcMapFiles.ref.md b/docs/files/NpcInGame/NpcMapFiles.ref.md index 3093faaa..61cfde5c 100644 --- a/docs/files/NpcInGame/NpcMapFiles.ref.md +++ b/docs/files/NpcInGame/NpcMapFiles.ref.md @@ -25,7 +25,7 @@ | 4 | npc_ini_id | NPC visual-archetype ID from `Npc.ini`. | | 8 | name | 260-byte Windows-1250 display name. | | 268 | role_description | 260-byte Windows-1250 role/description text. | -| 528 | party_member_slot | Values 1–8 select party/recruitment NPC behavior. | +| 528 | role_result | 0 normal dialogue; 1–8 party-member dialogue; 9–12 select shop or inn UI. | | 532 | show_on_event | Event condition controlling visibility. | | 536 | movement_mode | 0 static, 1 waypoint patrol, 2 random movement in activation rectangle. | | 540 | goto1_filled..goto4_filled | Four waypoint-enabled flags. | diff --git a/src/queries/create_table_npc_refs.sql b/src/queries/create_table_npc_refs.sql index 080bc9ee..35e7f106 100644 --- a/src/queries/create_table_npc_refs.sql +++ b/src/queries/create_table_npc_refs.sql @@ -6,7 +6,7 @@ CREATE TABLE npc_refs npc_ini_id INTEGER, name TEXT, role_description TEXT, - party_member_slot INTEGER, + role_result INTEGER, show_on_event INTEGER, movement_mode INTEGER, goto1_filled INTEGER, diff --git a/src/queries/insert_npc_ref.sql b/src/queries/insert_npc_ref.sql index 7c718936..632f840d 100644 --- a/src/queries/insert_npc_ref.sql +++ b/src/queries/insert_npc_ref.sql @@ -4,7 +4,7 @@ INSERT OR REPLACE INTO npc_refs(file_id, npc_ini_id, name, role_description, - party_member_slot, + role_result, show_on_event, movement_mode, goto1_filled, diff --git a/src/references/enums.rs b/src/references/enums.rs index 9f49df51..51972d80 100644 --- a/src/references/enums.rs +++ b/src/references/enums.rs @@ -998,6 +998,82 @@ impl std::fmt::Display for NpcMovementMode { } } +/// Role behavior selected by an NPC placement record. +/// +/// Values 1 through 8 select one of the party-member dialogue slots. The +/// remaining values select the normal dialogue or a specific service UI. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[repr(i32)] +pub enum NpcRoleResult { + /// Normal NPC dialogue. + #[default] + NormalDialogue = 0, + PartyMemberDialogue1 = 1, + PartyMemberDialogue2 = 2, + PartyMemberDialogue3 = 3, + PartyMemberDialogue4 = 4, + PartyMemberDialogue5 = 5, + PartyMemberDialogue6 = 6, + PartyMemberDialogue7 = 7, + PartyMemberDialogue8 = 8, + WeaponShop = 9, + HealMiscShop = 10, + EditItemShop = 11, + Inn = 12, +} + +impl NpcRoleResult { + pub fn from_i32(value: i32) -> Option { + match value { + 0 => Some(Self::NormalDialogue), + 1 => Some(Self::PartyMemberDialogue1), + 2 => Some(Self::PartyMemberDialogue2), + 3 => Some(Self::PartyMemberDialogue3), + 4 => Some(Self::PartyMemberDialogue4), + 5 => Some(Self::PartyMemberDialogue5), + 6 => Some(Self::PartyMemberDialogue6), + 7 => Some(Self::PartyMemberDialogue7), + 8 => Some(Self::PartyMemberDialogue8), + 9 => Some(Self::WeaponShop), + 10 => Some(Self::HealMiscShop), + 11 => Some(Self::EditItemShop), + 12 => Some(Self::Inn), + _ => None, + } + } + + pub fn from_name(name: &str) -> Option { + match name { + "NormalDialogue" => Some(Self::NormalDialogue), + "PartyMemberDialogue1" => Some(Self::PartyMemberDialogue1), + "PartyMemberDialogue2" => Some(Self::PartyMemberDialogue2), + "PartyMemberDialogue3" => Some(Self::PartyMemberDialogue3), + "PartyMemberDialogue4" => Some(Self::PartyMemberDialogue4), + "PartyMemberDialogue5" => Some(Self::PartyMemberDialogue5), + "PartyMemberDialogue6" => Some(Self::PartyMemberDialogue6), + "PartyMemberDialogue7" => Some(Self::PartyMemberDialogue7), + "PartyMemberDialogue8" => Some(Self::PartyMemberDialogue8), + "WeaponShop" => Some(Self::WeaponShop), + "HealMiscShop" => Some(Self::HealMiscShop), + "EditItemShop" => Some(Self::EditItemShop), + "Inn" => Some(Self::Inn), + _ => None, + } + } +} + +impl From for i32 { + fn from(value: NpcRoleResult) -> Self { + value as i32 + } +} + +impl std::fmt::Display for NpcRoleResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + /// Selects the NPC's special interaction-result behavior. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[repr(i32)] diff --git a/src/references/npc_ref.rs b/src/references/npc_ref.rs index e9a3a7c0..b131bdb6 100644 --- a/src/references/npc_ref.rs +++ b/src/references/npc_ref.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::references::enums::{ BooleanFlag, InventoryItem, ItemTypeId, NpcInteractionMode, NpcLookingDirection, - NpcMovementMode, + NpcMovementMode, NpcRoleResult, }; use crate::references::extractor::Extractor; use dispel_macros::{Extractor, Localizable, RecordLayout, RecordPatcher}; @@ -47,9 +47,9 @@ pub struct NPC { #[translatable(encoding = "WINDOWS_1250", max_bytes = 260)] #[extractor(string(encoding = "WINDOWS-1250", size = 260))] pub role_description: String, - /// Party-member slot. Values 1–8 use the party/recruitment NPC logic. - #[extractor(primitive(type = "i32"))] - pub party_member_slot: i32, + /// Role behavior selected when the NPC is interacted with. + #[extractor(enum_from_i32(type = "NpcRoleResult"))] + pub role_result: NpcRoleResult, /// Event ID condition required to spawn NPC. #[extractor(primitive(type = "i32"))] pub show_on_event: i32, @@ -187,7 +187,7 @@ pub fn save_npc_refs( }, npc.name, npc.role_description, - npc.party_member_slot, + i32::from(npc.role_result), if npc.show_on_event == 0 { None } else { @@ -292,6 +292,31 @@ mod tests { assert_eq!(npcs[1].name, "Mage"); } + #[test] + fn npc_role_result_maps_all_known_wire_values() { + let expected = [ + NpcRoleResult::NormalDialogue, + NpcRoleResult::PartyMemberDialogue1, + NpcRoleResult::PartyMemberDialogue2, + NpcRoleResult::PartyMemberDialogue3, + NpcRoleResult::PartyMemberDialogue4, + NpcRoleResult::PartyMemberDialogue5, + NpcRoleResult::PartyMemberDialogue6, + NpcRoleResult::PartyMemberDialogue7, + NpcRoleResult::PartyMemberDialogue8, + NpcRoleResult::WeaponShop, + NpcRoleResult::HealMiscShop, + NpcRoleResult::EditItemShop, + NpcRoleResult::Inn, + ]; + + for (value, role) in expected.into_iter().enumerate() { + assert_eq!(NpcRoleResult::from_i32(value as i32), Some(role)); + assert_eq!(i32::from(role), value as i32); + } + assert_eq!(NpcRoleResult::from_i32(13), None); + } + #[test] fn parse_runtime_mapped_fields_at_their_wire_offsets() { let mut rec = npc_bytes(42, "Inkeeper", 81); @@ -316,7 +341,7 @@ mod tests { data.extend(rec); let npc = NPC::parse(&mut Cursor::new(data), 676).unwrap().remove(0); - assert_eq!(npc.party_member_slot, 7); + assert_eq!(npc.role_result, NpcRoleResult::PartyMemberDialogue7); assert_eq!(npc.show_on_event, 42); assert_eq!(npc.movement_mode, NpcMovementMode::RandomInActivationRect); assert_eq!(npc.goto1_filled, BooleanFlag::True); From 9ecea3a5cd7580a4cb6dbb2c8cc35a99375b126b Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:16:03 +0200 Subject: [PATCH 36/49] Reconstruct map reader --- .../src/editors/map_editor/canvas/tests.rs | 2 +- .../src/editors/map_editor/update/entity.rs | 2 +- .../src/editors/map_editor/update/map.rs | 2 +- docs/files/Map/Map.map.md | 74 +++-- src/database.rs | 16 +- src/map/mod.rs | 312 ++++++++++++++---- src/map/reader.rs | 276 +++++++++------- src/map/types.rs | 221 +++++++++++-- src/map/writer.rs | 6 +- .../create_table_map_bundle_entries.sql | 20 ++ .../create_table_map_bundle_entry_ids.sql | 10 + src/queries/create_table_map_bundle_flags.sql | 8 + src/queries/create_table_map_bundle_items.sql | 10 + .../create_table_map_bundle_records.sql | 9 + src/queries/create_table_map_bundles.sql | 5 + .../create_table_map_object_metadata.sql | 20 -- src/queries/create_table_map_object_refs.sql | 4 +- src/queries/insert_map_bundle.sql | 2 + src/queries/insert_map_bundle_entry.sql | 2 + src/queries/insert_map_bundle_entry_id.sql | 2 + src/queries/insert_map_bundle_flag.sql | 2 + src/queries/insert_map_bundle_item.sql | 2 + src/queries/insert_map_bundle_record.sql | 2 + src/queries/insert_map_object_metadata.sql | 2 - src/queries/insert_map_object_ref.sql | 2 +- 25 files changed, 759 insertions(+), 254 deletions(-) create mode 100644 src/queries/create_table_map_bundle_entries.sql create mode 100644 src/queries/create_table_map_bundle_entry_ids.sql create mode 100644 src/queries/create_table_map_bundle_flags.sql create mode 100644 src/queries/create_table_map_bundle_items.sql create mode 100644 src/queries/create_table_map_bundle_records.sql create mode 100644 src/queries/create_table_map_bundles.sql delete mode 100644 src/queries/create_table_map_object_metadata.sql create mode 100644 src/queries/insert_map_bundle.sql create mode 100644 src/queries/insert_map_bundle_entry.sql create mode 100644 src/queries/insert_map_bundle_entry_id.sql create mode 100644 src/queries/insert_map_bundle_flag.sql create mode 100644 src/queries/insert_map_bundle_item.sql create mode 100644 src/queries/insert_map_bundle_record.sql delete mode 100644 src/queries/insert_map_object_metadata.sql diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs index f7e10fc5..4cbd2602 100644 --- a/dispel-gui/src/editors/map_editor/canvas/tests.rs +++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs @@ -64,7 +64,7 @@ fn make_state( sprite_blocks: vec![], internal_sprite_stamps: Vec::new(), tiled_object_refs: Vec::new(), - tiled_object_metadata: Vec::new(), + tiled_bundles: Vec::new(), }; let mut state = MapEditorState::default(); diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs index 4b61c839..b955e36b 100644 --- a/dispel-gui/src/editors/map_editor/update/entity.rs +++ b/dispel-gui/src/editors/map_editor/update/entity.rs @@ -517,7 +517,7 @@ mod tests { sprite_blocks: vec![], internal_sprite_stamps: Vec::new(), tiled_object_refs: Vec::new(), - tiled_object_metadata: Vec::new(), + tiled_bundles: Vec::new(), }; state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index 5a1b4577..69d7f359 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -1011,7 +1011,7 @@ mod tests { sprite_blocks: vec![], internal_sprite_stamps: Vec::new(), tiled_object_refs: Vec::new(), - tiled_object_metadata: Vec::new(), + tiled_bundles: Vec::new(), }; let state = app.state.editors.map_editors.entry(TAB).or_default(); state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data))); diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index 9723dc37..8d519c26 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -56,12 +56,12 @@ entries each, so a reader can seek to them from the end of the file. ``` count: i32 -records: (count − 1) × 8 bytes -- each record: value1: i32, value2: i32 +records: (count − 1) × 8 bytes -- each record: word0: i32, word1: i32 ``` Skipping `(count − 1) × 8` lands exactly on the next block's size field, verified against the cat1/cat3/dun01/map1/catp -fixtures. In-game, each record's second i32 (`value2`) is a **linear tile index** (`y × stride + x`) into the three -end grids — the game's tiled-object renderer and access checks resolve tiles through it. +fixtures. Both words are **constant across all shipped maps** — `word0 == 0` and `word1 == 1` in all 99,104 records +probed — so their purpose is unknown. The earlier "linear tile index into the end grids" interpretation was wrong. ### Second Block *(u16 lookup table)* @@ -111,26 +111,62 @@ the game stores them in separate per-frame arrays so they can diverge). Verified against cat1/map1: `right − left` and `bottom − top` equal the frame's pixel `width × height` exactly. Placements Y-sort by `bottom`. -### Tiled Objects Block *(buildings)* +### Tiled Objects Block *(buildings)* — fully typed + +This block is a three-level tree: bundle, record, item, and entry. The +structure below is based on the on-disk data. ``` bundle_count: i32 -number1: i32 -- always observed as 1 for each bundle: - metadata: 264 bytes - control: 4 × i32 -- expected pattern: 8, 0, 1, 0 - v1..v4: 4 × i32 -- unknown - x: i32 -- stack anchor, map-local pixels - y: i32 - v7, v8: 2 × i32 -- unknown - c1, c2, c3: 3 × i32 -- counts - tile_ids: c3 × i16 -- BTL tile stack, top → bottom - unknown_a: 84 bytes - unknown_b: (c1 + c2 + c3) × 4 bytes - -- see tiled_objects_block() in reader.rs -``` - -Each bundle is one building: its `tile_ids` stack is drawn downward from the anchor, one 62×32 diamond per entry. + record_count: i32 + for each record: + field_04: i32 -- observed as 0 in shipped maps + body: 260 bytes -- binary metadata, not text + item_count: i32 + for each item: + type_flag: i32 -- 1 ⇒ entries carry extra bytes + entry_count: i32 + field_14: i32 -- always 0 in all shipped maps + for each entry: + bound_x, bound_y, -- bounding box, map pixels: + bound_right, bound_bottom right = x + 64 and + bottom = y + grid_height*32, + exact across all shipped entries + anchor_x, anchor_y -- stack anchor, map-local pixels + draw_x, draw_y -- position terms used during + -- drawing relative to the camera + grid_width -- constant 1 in all shipped maps + grid_height -- equals ids.len() + stored_cell_count -- equals grid_width*grid_height + -- in all shipped maps + ids: stored_cell_count × u16 -- BTL tile stack of this entry + if type_flag == 1: + extra_payload: stored_cell_count bytes + n = first_record.first_item.entries[0].grid_height + level_flags: n × (flag_a: i32, flag_b: i32) -- two binary 0/1 flags per + -- stack level; their meaning is unknown +``` + +There is **no end-of-block sentinel**: the three end grids follow the last +bundle's level flags immediately. The previously described "empirically fitted" +layout (`264-byte blob`, `control words`, `84-byte trailer`, +`sentinel alignment scan`) was an approximation that happened to consume the +same byte stream; it has been deleted. + +Discovered semantics (verified over all 33 fixture maps, 43,554 entries): +- `anchor_x`/`anchor_y` are the stack anchor `(x, y)` in map-local pixels; + the entry's `ids` u16 array is its BTL tile stack. The renderer-facing + `TiledObjectInfo` is derived from the bundle's *first* entry + (first record → first item → first entry). +- The bbox relations above hold exactly on every shipped entry. +- An entry's `grid_height` sizes the per-bundle flag arrays that follow. + +Shipped data is degenerate: exactly one record per bundle, eight items per +record, at most one entry per item; `type_flag ≡ 0` and `field_14 ≡ 0` +everywhere. + +Each bundle's first-entry tile stack is drawn downward from the anchor, one 62×32 diamond per id. Negative ids occur and are skipped when drawing. ### Event Block diff --git a/src/database.rs b/src/database.rs index 89f11a90..54df1a0b 100644 --- a/src/database.rs +++ b/src/database.rs @@ -35,7 +35,12 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { "magic_spells", "map_inis", "map_metadata", - "map_object_metadata", + "map_bundles", + "map_bundle_records", + "map_bundle_items", + "map_bundle_entries", + "map_bundle_entry_ids", + "map_bundle_flags", "map_object_refs", "map_objects", "map_overlay_modes", @@ -111,7 +116,14 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { "queries/create_table_map_sprite_sequences.sql" ))?; conn.execute_batch(include_str!("queries/create_table_map_object_refs.sql"))?; - conn.execute_batch(include_str!("queries/create_table_map_object_metadata.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_bundles.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_bundle_records.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_bundle_items.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_bundle_entries.sql"))?; + conn.execute_batch(include_str!( + "queries/create_table_map_bundle_entry_ids.sql" + ))?; + conn.execute_batch(include_str!("queries/create_table_map_bundle_flags.sql"))?; conn.execute_batch(include_str!("queries/create_table_dialogue_paragraphs.sql"))?; conn.execute_batch(include_str!("queries/create_table_party_levels.sql"))?; conn.execute_batch(include_str!("queries/create_table_party_inis.sql"))?; diff --git a/src/map/mod.rs b/src/map/mod.rs index ce19f351..27cd43f2 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -28,10 +28,9 @@ // | - Count (i32) | // | - Data: (count-1)*8 bytes | // | (count-1 records of 2×i32; | -// | value2 = linear tile index| -// | into the three end grids, | -// | used by tiled-object | -// | rendering) | +// | word0 ≡ 0 and word1 ≡ 1 | +// | across all shipped maps — | +// | semantics unknown) | // +------------------------------+ // | SECOND BLOCK (variable) | // | - Size (i32) | @@ -60,12 +59,18 @@ // +------------------------------+ // | TILED OBJECTS BLOCK | // | - Bundle count (i32) | -// | For each bundle: | -// | - 264 bytes metadata | -// | - Control words + params | -// | - Anchor (x,y) map px | -// | - BTL tile stack IDs | -// | - Building definition | +// | For each bundle (a tree): | +// | - records {field_04 i32, | +// | body 260 B, items} | +// | - items {type_flag, | +// | entry_count, entries} | +// | - entries {16 B payload, | +// | bbox + 7 i32 fields, | +// | u16 tile stack, | +// | optional extra bytes} | +// | - n flag pairs | +// | (n = grid_height of the | +// | first entry) | // +------------------------------+ // | ... (file continues) ... | // +------------------------------+ @@ -113,8 +118,9 @@ pub use self::fogdata::{FogData, save_to_db as save_fog_to_db}; pub use model::{MapModel, read_map_model}; pub use render::{EntityRenderInfo, ExternalEntities, LayerToggles}; pub use types::{ - Coords, EventBlock, SpriteInfoBlock, TILE_HEIGHT_HALF, TILE_HORIZONTAL_OFFSET_HALF, - TILE_PIXEL_NUMBER, TILE_WIDTH_HALF, TiledObjectInfo, convert_map_coords_to_image_coords, + BundleEntry, BundleItem, BundleRecord, Coords, EventBlock, SpriteInfoBlock, StackLevelFlags, + TILE_HEIGHT_HALF, TILE_HORIZONTAL_OFFSET_HALF, TILE_PIXEL_NUMBER, TILE_WIDTH_HALF, TiledBundle, + TiledObjectInfo, TiledObjectRef, convert_map_coords_to_image_coords, }; use std::collections::HashMap; @@ -139,7 +145,6 @@ use reader::{ read_tiles_and_access_block, sprite_block, sprite_info_block, tiled_objects_block, }; use render::{MapRenderConfig, render_map}; -use types::TiledObjectMetadata; // -------------------------------------------------------------------------- // MapData – the in-memory representation of a parsed .map file @@ -165,11 +170,12 @@ pub struct MapData { /// Image stamp per embedded sprite (6 or 9), parallel to /// `internal_sprites` — same length, same order. pub internal_sprite_stamps: Vec, - /// First-block tiled-object ref records `(value0, value1)` in file order. - pub tiled_object_refs: Vec<(i32, i32)>, - /// Per-bundle retained metadata, parallel to `tiled_infos` — same length, - /// same order. - pub tiled_object_metadata: Vec, + /// First-block tiled-object ref records in file order (both words are + /// constant across all shipped maps). + pub tiled_object_refs: Vec, + /// Fully typed tiled-bundle trees, parallel to `tiled_infos` — same + /// length, same order. + pub tiled_bundles: Vec, } /// JSON-serializable representation of map data. @@ -411,7 +417,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { let (internal_sprites, internal_sprite_stamps) = sprite_block(reader)?; let sprite_blocks = sprite_info_block(reader, &internal_sprites)?; - let (tiled_infos, tiled_object_metadata) = tiled_objects_block(reader)?; + let (tiled_infos, tiled_bundles) = tiled_objects_block(reader)?; // Event and tile blocks live at the end of the file // Calculate expected size for the three end blocks @@ -471,7 +477,7 @@ pub fn read_map_data(reader: &mut BufReader) -> IoResult { sprite_blocks, internal_sprite_stamps, tiled_object_refs, - tiled_object_metadata, + tiled_bundles, }) } @@ -705,7 +711,7 @@ pub fn save_to_db( save_map_object_refs(conn, map_id, &data.tiled_object_refs)?; - save_map_object_metadata(conn, map_id, &data.tiled_infos, &data.tiled_object_metadata)?; + save_map_bundles(conn, map_id, &data.tiled_bundles)?; Ok(()) } @@ -979,53 +985,110 @@ pub fn save_map_sprite_sequences( pub fn save_map_object_refs( conn: &mut Connection, map_id: &str, - refs: &[(i32, i32)], + refs: &[TiledObjectRef], ) -> DbResult<()> { let tx = conn.transaction()?; { let mut stmt = tx.prepare(include_str!("../queries/insert_map_object_ref.sql"))?; - for (ref_index, &(value0, value1)) in refs.iter().enumerate() { - stmt.execute(params![map_id, ref_index as i32, value0, value1])?; + for (ref_index, r) in refs.iter().enumerate() { + stmt.execute(params![map_id, ref_index as i32, r.word0, r.word1])?; } } tx.commit()?; Ok(()) } -/// Persists per-bundle retained metadata from the tiled objects block. +/// Persists the typed tiled-bundle trees into the six `map_bundle*` tables. /// -/// Defensive guard: if `metadata.len() != tiled_infos.len()` (should not -/// happen — both come from one parse pass), whatever exists is still written -/// indexed positionally and the export continues. -pub fn save_map_object_metadata( +/// All rows go in one transaction; a failure anywhere rolls the whole map's +/// bundle export back. Row counts are derivable from the tree: one +/// `map_bundles` row per bundle, one record/item/entry row per node, and one +/// id row per `(entry, seq)`. +pub fn save_map_bundles( conn: &mut Connection, map_id: &str, - _tiled_infos: &[TiledObjectInfo], - metadata: &[TiledObjectMetadata], + bundles: &[TiledBundle], ) -> DbResult<()> { let tx = conn.transaction()?; { - let mut stmt = tx.prepare(include_str!("../queries/insert_map_object_metadata.sql"))?; - for (obj_idx, meta) in metadata.iter().enumerate() { - stmt.execute(params![ - map_id, - obj_idx as i32, - meta.metadata_blob, - meta.control_0, - meta.control_1, - meta.control_2, - meta.control_3, - meta.param_0, - meta.param_1, - meta.param_2, - meta.param_3, - meta.param_4, - meta.param_5, - meta.extra_count_a, - meta.extra_count_b, - meta.trailing_fixed, - meta.trailing_variable, - ])?; + let mut bundle_stmt = tx.prepare(include_str!("../queries/insert_map_bundle.sql"))?; + let mut record_stmt = + tx.prepare(include_str!("../queries/insert_map_bundle_record.sql"))?; + let mut item_stmt = tx.prepare(include_str!("../queries/insert_map_bundle_item.sql"))?; + let mut entry_stmt = tx.prepare(include_str!("../queries/insert_map_bundle_entry.sql"))?; + let mut entry_id_stmt = + tx.prepare(include_str!("../queries/insert_map_bundle_entry_id.sql"))?; + let mut flag_stmt = tx.prepare(include_str!("../queries/insert_map_bundle_flag.sql"))?; + + for (bundle_index, bundle) in bundles.iter().enumerate() { + bundle_stmt.execute(params![map_id, bundle_index as i32])?; + + for (level_index, flags) in bundle.level_flags.iter().enumerate() { + flag_stmt.execute(params![ + map_id, + bundle_index as i32, + level_index as i32, + flags.flag_a, + flags.flag_b + ])?; + } + + for (record_index, record) in bundle.records.iter().enumerate() { + record_stmt.execute(params![ + map_id, + bundle_index as i32, + record_index as i32, + record.field_04, + record.body, + record.items.len() as i32, + ])?; + + for (item_index, item) in record.items.iter().enumerate() { + item_stmt.execute(params![ + map_id, + bundle_index as i32, + record_index as i32, + item_index as i32, + item.type_flag, + item.field_14, + item.entries.len() as i32, + ])?; + + for (entry_index, entry) in item.entries.iter().enumerate() { + entry_stmt.execute(params![ + map_id, + bundle_index as i32, + record_index as i32, + item_index as i32, + entry_index as i32, + entry.bound_x, + entry.bound_y, + entry.bound_right, + entry.bound_bottom, + entry.anchor_x, + entry.anchor_y, + entry.draw_x, + entry.draw_y, + entry.grid_width, + entry.grid_height, + entry.stored_cell_count, + entry.extra_payload, + ])?; + + for (seq, &id) in entry.ids.iter().enumerate() { + entry_id_stmt.execute(params![ + map_id, + bundle_index as i32, + record_index as i32, + item_index as i32, + entry_index as i32, + seq as i32, + id as i32, + ])?; + } + } + } + } } } tx.commit()?; @@ -1052,7 +1115,7 @@ mod shadow_tests { sprite_blocks: Vec::new(), internal_sprite_stamps: Vec::new(), tiled_object_refs: Vec::new(), - tiled_object_metadata: Vec::new(), + tiled_bundles: Vec::new(), }; // No shadows yet @@ -1117,10 +1180,24 @@ mod db_export_tests { .unwrap(); conn.execute_batch(include_str!("../queries/create_table_map_object_refs.sql")) .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_bundles.sql")) + .unwrap(); conn.execute_batch(include_str!( - "../queries/create_table_map_object_metadata.sql" + "../queries/create_table_map_bundle_records.sql" )) .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_bundle_items.sql")) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_bundle_entries.sql" + )) + .unwrap(); + conn.execute_batch(include_str!( + "../queries/create_table_map_bundle_entry_ids.sql" + )) + .unwrap(); + conn.execute_batch(include_str!("../queries/create_table_map_bundle_flags.sql")) + .unwrap(); conn } @@ -1330,7 +1407,7 @@ mod db_export_tests { } #[test] - fn test_db_export_object_refs_and_metadata_match_parsed_data() { + fn test_db_export_object_refs_and_bundles_match_parsed_data() { let (data, mut reader) = parse_fixture(); let mut conn = setup_db(); save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); @@ -1340,10 +1417,131 @@ mod db_export_tests { data.tiled_object_refs.len() as i64 ); assert_eq!( - count(&conn, "SELECT COUNT(*) FROM map_object_metadata"), + count(&conn, "SELECT COUNT(*) FROM map_bundles"), data.tiled_infos.len() as i64, - "one metadata row per tiled object bundle" + "one bundle row per tiled object" + ); + } + + #[test] + fn test_db_export_bundle_tree_matches_parsed_data() { + let (data, mut reader) = parse_fixture(); + let mut conn = setup_db(); + save_to_db(&mut conn, "cat1", &data, &mut reader).unwrap(); + + let bundles = &data.tiled_bundles; + assert!(!bundles.is_empty(), "cat1 must contain bundles"); + + // Aggregate expected node counts from the parsed trees. + let mut records = 0i64; + let mut items = 0i64; + let mut entries = 0i64; + let mut ids = 0i64; + for bundle in bundles { + for record in &bundle.records { + records += 1; + for item in &record.items { + items += 1; + for entry in &item.entries { + entries += 1; + ids += entry.ids.len() as i64; + } + } + } + } + + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_bundle_records"), + records + ); + assert_eq!(count(&conn, "SELECT COUNT(*) FROM map_bundle_items"), items); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_bundle_entries"), + entries ); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_bundle_entry_ids"), + ids + ); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM map_bundle_flags"), + bundles + .iter() + .map(|b| b.level_flags.len() as i64) + .sum::() + ); + + // Sampled deep check: first bundle's first entry must round-trip its + // u16 id array and its 260-byte record body length. + let entry0 = bundles[0] + .records + .first() + .unwrap() + .items + .first() + .unwrap() + .entries + .first() + .unwrap(); + + let body_len: i64 = conn + .query_row( + "SELECT LENGTH(body) FROM map_bundle_records \ + WHERE bundle_index = 0 AND record_index = 0", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(body_len, 260, "record body BLOB must be the full 260 bytes"); + + let stored: Vec = conn + .prepare( + "SELECT id FROM map_bundle_entry_ids \ + WHERE bundle_index = 0 AND record_index = 0 AND item_index = 0 \ + AND entry_index = 0 ORDER BY seq", + ) + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + stored, + entry0.ids.iter().map(|&v| v as i64).collect::>(), + "stored entry ids must equal the parsed u16 array in order" + ); + + // Flag count of bundle 0 == first entry's grid_height. + let flag_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM map_bundle_flags WHERE bundle_index = 0", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + flag_count as i32, + bundles[0].first_entry().unwrap().grid_height + ); + + // Bounds assertions on the sampled entry: right/bottom relations hold + // exactly across every shipped entry. + assert_eq!(entry0.bound_x, entry0.anchor_x); + assert_eq!(entry0.bound_y, entry0.anchor_y); + assert_eq!(entry0.bound_right, entry0.bound_x + 64); + assert_eq!( + entry0.bound_bottom, + entry0.bound_y + entry0.grid_height * 32 + ); + assert_eq!( + entry0.stored_cell_count, + entry0.grid_width * entry0.grid_height + ); + + // Ref records spot-check: both words constant on shipped maps. + for r in data.tiled_object_refs.iter().take(10) { + assert_eq!((r.word0, r.word1), (0, 1)); + } } #[test] diff --git a/src/map/reader.rs b/src/map/reader.rs index 379cafbb..754b0cba 100644 --- a/src/map/reader.rs +++ b/src/map/reader.rs @@ -21,38 +21,30 @@ use std::io::{BufReader, Read, Result, Seek, SeekFrom}; use crate::sprite; use crate::sprite::SequenceInfo; -use super::types::{Coords, EventBlock, SpriteInfoBlock, TiledObjectInfo, TiledObjectMetadata}; +use super::types::{ + BundleEntry, BundleItem, BundleRecord, Coords, EventBlock, SpriteInfoBlock, StackLevelFlags, + TiledBundle, TiledObjectInfo, TiledObjectRef, +}; // -------------------------------------------------------------------------- // Skipped blocks (parsed by the format but not persisted in MapData) // -------------------------------------------------------------------------- /// Tiled-object ref records: `count` (i32) followed by `(count - 1)` records -/// of 2 × i32 each (`value1`, `value2`). +/// of 2 × i32 each. /// -/// `value2` is a linear tile index (`y * stride + x`) into the three end -/// grids — tiled-object rendering and access checks resolve tiles -/// through it. -/// -/// Verified against cat1/cat3/dun01/map1/catp fixtures: skipping `(count-1)*8` -/// lands exactly on the overlay table's size field, while `count*8` lands on -/// `0x01010101` garbage. The previous `multiplier*size*4` skip only worked by -/// coincidence (`8 + 2*count*4 == 16 + (count-1)*8`). -/// Reads (instead of skipping) the tiled-object ref records: `count` (i32) -/// followed by `(count - 1)` records of 2 × i32 each (`value1`, `value2`). -/// -/// `value2` is a linear tile index (`y * stride + x`) into the three end -/// grids — tiled-object rendering and access checks resolve tiles -/// through it. +/// Both words are constant across ALL shipped maps (`word0 == 0`, +/// `word1 == 1` in all 99,104 records probed); their semantics are unknown. +/// The earlier "linear tile index into the end grids" interpretation was +/// wrong. See [`TiledObjectRef`]. /// /// Verified against cat1/cat3/dun01/map1/catp fixtures: reading `(count-1)` /// pairs lands exactly on the overlay table's size field, while `count*8` -/// lands on `0x01010101` garbage. The previous `multiplier*size*4` skip only -/// worked by coincidence (`8 + 2*count*4 == 16 + (count-1)*8`). +/// lands on `0x01010101` garbage. /// /// A count below 1 cannot be honored (the format has no negative-record /// semantics) and yields a clear error instead of a silent negative seek. -pub fn read_tiled_object_refs(reader: &mut BufReader) -> Result> { +pub fn read_tiled_object_refs(reader: &mut BufReader) -> Result> { let count = reader.read_i32::()?; if count < 1 { return Err(std::io::Error::new( @@ -62,9 +54,9 @@ pub fn read_tiled_object_refs(reader: &mut BufReader) -> Result()?; - let value1 = reader.read_i32::()?; - refs.push((value0, value1)); + let word0 = reader.read_i32::()?; + let word1 = reader.read_i32::()?; + refs.push(TiledObjectRef { word0, word1 }); } Ok(refs) } @@ -169,117 +161,169 @@ pub fn sprite_info_block( // -------------------------------------------------------------------------- // Tiled objects block – building definitions composed of BTL tile stacks // -// Each bundle is a building placed on the map. The file parser reads, per -// bundle: a sub-record count, then per sub-record {i32 stamp, 260-byte name/ -// metadata block, item count, items}; each item carries seven i32 fields plus -// a u16 array (tile-stack ids) and an optional byte array when the item's -// first i32 == 1. The Rust reader below flattens this byte stream with an -// empirically fitted layout that round-trips all known maps. +// Each bundle contains records, items, and entries. An entry contains four +// bounding-box words, seven position and size words, tile IDs, and optional +// extra bytes. The number of level-flag pairs equals the grid height of the +// first entry. +// +// The three end grids follow immediately after the last bundle's flags — +// the format has no end-of-block sentinel. The previous empirical parser's +// "sentinel alignment scan" is gone; any decode error now surfaces as an +// explicit `Err` instead of being papered over by a resync hack. // -------------------------------------------------------------------------- -/// Returns `(infos, metadata)` — one [`TiledObjectMetadata`] per bundle, same -/// order as the returned [`TiledObjectInfo`]s. +fn read_bundle_entry(reader: &mut BufReader, type_flag: i32) -> Result { + let bound_x = reader.read_i32::()?; + let bound_y = reader.read_i32::()?; + let bound_right = reader.read_i32::()?; + let bound_bottom = reader.read_i32::()?; + + let anchor_x = reader.read_i32::()?; + let anchor_y = reader.read_i32::()?; + let draw_x = reader.read_i32::()?; + let draw_y = reader.read_i32::()?; + let grid_width = reader.read_i32::()?; + let grid_height = reader.read_i32::()?; + let stored_cell_count = reader.read_i32::()?; + + // stored_cell_count sizes both trailing arrays of the entry. Reject + // absurd counts early so that a malformed file returns an error. + if !(0..=100_000).contains(&stored_cell_count) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-bundle entry id count {stored_cell_count}"), + )); + } + + let mut ids = Vec::with_capacity(stored_cell_count as usize); + for _ in 0..stored_cell_count { + ids.push(reader.read_u16::()?); + } + + let extra_payload = if type_flag == 1 { + let mut bytes = vec![0u8; stored_cell_count as usize]; + reader.read_exact(&mut bytes)?; + Some(bytes) + } else { + None + }; + + Ok(BundleEntry { + bound_x, + bound_y, + bound_right, + bound_bottom, + anchor_x, + anchor_y, + draw_x, + draw_y, + grid_width, + grid_height, + stored_cell_count, + ids, + extra_payload, + }) +} + +/// Returns `(infos, bundles)` — one [`TiledObjectInfo`] per bundle, same +/// order as the returned [`TiledBundle`]s. pub fn tiled_objects_block( reader: &mut BufReader, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { let bundles_count = reader.read_i32::()?; - // Sub-record count inside the bundle: sub-records of {stamp, 260-byte - // metadata, items} follow at this point. - let _sub_record_count = reader.read_i32::()?; - - let mut infos: Vec = Vec::with_capacity(bundles_count.unsigned_abs() as usize); - let mut metadata: Vec = - Vec::with_capacity(bundles_count.unsigned_abs() as usize); - for _ in 0..bundles_count { - // 264 bytes: stamp i32 + metadata block (building name/definition). - // Read verbatim (stamp included) so the DB export is lossless. - let mut metadata_blob = vec![0u8; 264]; - reader.read_exact(&mut metadata_blob)?; - - // Control words — always observed as (8, 0, 1, 0) in known maps. - let control_0 = reader.read_i32::()?; - let control_1 = reader.read_i32::()?; - let control_2 = reader.read_i32::()?; - let control_3 = reader.read_i32::()?; - - // Unmapped parameters preceding the stack anchor. - let param_0 = reader.read_i32::()?; - let param_1 = reader.read_i32::()?; - let param_2 = reader.read_i32::()?; - let param_3 = reader.read_i32::()?; - - // Anchor of the building stack in map-local pixel coordinates. - let x = reader.read_i32::()?; - let y = reader.read_i32::()?; - - // Unmapped parameters following the anchor. - let param_4 = reader.read_i32::()?; - let param_5 = reader.read_i32::()?; - - // Trailing counts. Only `tile_stack_len` BTL tile ids follow here; - // the other two counts gate data covered by the skip below. - let extra_count_a = reader.read_i32::()?; - let extra_count_b = reader.read_i32::()?; - let tile_stack_len = reader.read_i32::()?; - - // BTL tile stack for this building, top → bottom. - let mut ids: Vec = vec![]; - for _ in 0..tile_stack_len { - ids.push(reader.read_i16::()?); + if bundles_count < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-bundle count {bundles_count}"), + )); + } + + let mut infos = Vec::with_capacity(bundles_count as usize); + let mut bundles = Vec::with_capacity(bundles_count as usize); + + for _bundle_index in 0..bundles_count { + let record_count = reader.read_i32::()?; + if !(0..=10_000).contains(&record_count) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-bundle record count {record_count}"), + )); } - infos.push(TiledObjectInfo { ids, x, y }); + let mut records = Vec::with_capacity(record_count as usize); + for _record_index in 0..record_count { + let field_04 = reader.read_i32::()?; + let mut body = [0u8; 260]; + reader.read_exact(&mut body)?; + + let item_count = reader.read_i32::()?; + if !(0..=10_000).contains(&item_count) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-bundle item count {item_count}"), + )); + } - // Fixed 84-byte trailer after the tile stack (read, not skipped). - let mut trailing_fixed = vec![0u8; 84]; - reader.read_exact(&mut trailing_fixed)?; + let mut items = Vec::with_capacity(item_count as usize); + for _item_index in 0..item_count { + let type_flag = reader.read_i32::()?; + let entry_count = reader.read_i32::()?; + let field_14 = reader.read_i32::()?; + if !(0..=10_000).contains(&entry_count) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid tiled-bundle entry count {entry_count}"), + )); + } + + let mut entries = Vec::with_capacity(entry_count as usize); + for _entry_index in 0..entry_count { + entries.push(read_bundle_entry(reader, type_flag)?); + } + items.push(BundleItem { + type_flag, + field_14, + entries, + }); + } + records.push(BundleRecord { + field_04, + body, + items, + }); + } - // Variable trailer sized from the three counts. - let var_len = (extra_count_a + extra_count_b + tile_stack_len) * 4; - if var_len < 0 { + // The first entry sets the number of flag pairs. + let n = records + .first() + .and_then(|r| r.items.first()) + .and_then(|i| i.entries.first()) + .map(|e| e.grid_height) + .unwrap_or(0); + if n < 0 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, - format!("Invalid tiled-object variable trailer length {var_len}"), + format!("Invalid tiled-bundle flag count {n}"), )); } - let mut trailing_variable = vec![0u8; var_len as usize]; - reader.read_exact(&mut trailing_variable)?; - - metadata.push(TiledObjectMetadata { - metadata_blob, - control_0, - control_1, - control_2, - control_3, - param_0, - param_1, - param_2, - param_3, - param_4, - param_5, - extra_count_a, - extra_count_b, - trailing_fixed, - trailing_variable, - }); - } + let mut level_flags = Vec::with_capacity(n as usize); + for _ in 0..n { + let flag_a = reader.read_i32::()?; + let flag_b = reader.read_i32::()?; + level_flags.push(StackLevelFlags { flag_a, flag_b }); + } + let bundle = TiledBundle { + records, + level_flags, + }; - // Align past the bundle-end sentinel - let back_pos = 20; - reader.seek(SeekFrom::Current(-back_pos))?; - let mut last_pos = 0u8; - for _ in 0..back_pos { - let v: u8 = reader.read_u8()?; - if v == 1 { - last_pos = v; + if let Some(info) = bundle.info() { + infos.push(info); } + bundles.push(bundle); } - let to_undo: i64 = back_pos; - reader.seek(SeekFrom::Current(to_undo))?; - let to_undo: i64 = last_pos.into(); - reader.seek(SeekFrom::Current(-to_undo - 4))?; - Ok((infos, metadata)) + Ok((infos, bundles)) } // -------------------------------------------------------------------------- diff --git a/src/map/types.rs b/src/map/types.rs index df716e75..81d91f84 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -1,5 +1,29 @@ use serde::{Deserialize, Serialize}; +/// Serde support for fixed-size byte arrays larger than 32 (serde's built-in +/// array impls stop at 32); serializes as a plain byte sequence. +mod bytes_array { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &[u8; N], s: S) -> Result { + s.serialize_bytes(v) + } + + pub fn deserialize<'de, const N: usize, D: Deserializer<'de>>( + d: D, + ) -> Result<[u8; N], D::Error> { + let vec = Vec::::deserialize(d)?; + if vec.len() != N { + return Err(serde::de::Error::invalid_length( + vec.len(), + &"exact byte array", + )); + } + let arr: [u8; N] = vec.try_into().expect("length just checked"); + Ok(arr) + } +} + /// Isometric (x, y) tile coordinate. pub type Coords = (i32, i32); @@ -102,40 +126,177 @@ pub struct SpriteInfoBlock { pub bbox_right: i32, } -/// Retained per-bundle metadata from the tiled objects block, preserved so the -/// DB export is lossless. The 264-byte bundle header is stored verbatim -/// (its first i32 is a stamp); control words, unmapped parameters and trailing -/// counts are captured as named fields. +/// One leaf record inside a tiled-bundle item. +/// +/// On-disk layout, per entry: +/// ```text +/// [i32 × 4] bounding box {bound_x, bound_y, bound_right, bound_bottom} +/// [i32 × 7] anchor_x, anchor_y, draw_x, draw_y, grid_width, +/// grid_height, stored_cell_count +/// [u16 × stored_cell_count] ids — BTL tile-stack ids for this entry +/// if type_flag == 1: +/// [u8 × stored_cell_count] extra_payload bytes +/// ``` +/// +/// Discovered field semantics (verified over all 33 shipped map fixtures, +/// 43,554 entries): +/// - The leading four i32 words are a **bounding box** in map pixels: +/// word0 == `anchor_x`, word1 == `anchor_y`, and in shipped maps always +/// `bound_right == bound_x + 64`, `bound_bottom == bound_y + grid_height * 32`. +/// - `anchor_x`/`anchor_y` are the stack anchor `(x, y)` in map-local pixels — +/// they surface as [`TiledObjectInfo::x`] / [`TiledObjectInfo::y`]. +/// - `draw_x` (signed) and `draw_y` are position terms for drawing relative to +/// the camera. They do not correlate with the anchors or the bounding box. +/// - `grid_width` is constant 1 across all shipped maps; `grid_height` +/// equals `ids.len()`. The `grid_height` of the first entry sizes the two +/// parallel flag arrays that follow ([`TiledBundle::level_flags`]). +/// - `stored_cell_count` is a redundant duplicate of +/// `grid_width * grid_height` with zero exceptions. It sizes both trailing +/// arrays of the entry on disk (IDs and optional payload). +/// +/// Shipped data is degenerate: exactly one record per bundle, eight items per +/// record, at most one entry per item; `BundleItem::type_flag ≡ 0` and +/// `BundleItem::field_14 ≡ 0` everywhere. +/// #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TiledObjectMetadata { - /// The raw 264-byte bundle header, first i32 stamp included verbatim. - pub metadata_blob: Vec, - /// Control words — always observed as `(8, 0, 1, 0)` in known maps. - pub control_0: i32, - pub control_1: i32, - pub control_2: i32, - pub control_3: i32, - /// Unmapped parameters preceding/following the stack anchor. - pub param_0: i32, - pub param_1: i32, - pub param_2: i32, - pub param_3: i32, - pub param_4: i32, - pub param_5: i32, - /// Trailing counts gating data covered by the post-stack skip. - pub extra_count_a: i32, - pub extra_count_b: i32, - /// The fixed 84-byte trailer after the tile stack (unmapped; retained - /// verbatim for lossless DB export). - pub trailing_fixed: Vec, - /// The variable trailer of `(extra_count_a + extra_count_b + - /// tile_stack_len) * 4` bytes after the fixed trailer (unmapped; retained - /// verbatim). - pub trailing_variable: Vec, +pub struct BundleEntry { + /// Left edge of the entry's bounding box in map pixels (== `anchor_x`). + pub bound_x: i32, + /// Top edge of the entry's bounding box in map pixels (== `anchor_y`). + pub bound_y: i32, + /// Right edge of the entry's bounding box in map pixels + /// (== `bound_x + 64` in every shipped entry). + pub bound_right: i32, + /// Bottom edge of the entry's bounding box in map pixels + /// (== `bound_y + grid_height * 32` in every shipped entry). + pub bound_bottom: i32, + /// Stack anchor X in map-local pixels. + pub anchor_x: i32, + /// Stack anchor Y in map-local pixels. + pub anchor_y: i32, + /// Signed X term subtracted from camera scroll during blit placement. + pub draw_x: i32, + /// Y term subtracted from camera scroll during blit placement. + pub draw_y: i32, + /// Grid width of the entry's cell layout (constant 1 across all shipped + /// maps). + pub grid_width: i32, + /// Grid height of the entry's cell layout; equals `ids.len()`. + pub grid_height: i32, + /// On-disk count for the ID and optional payload arrays. It equals + /// `grid_width * grid_height` in every shipped map. + pub stored_cell_count: i32, + /// `stored_cell_count` BTL overlay/tile ids (u16, little-endian) drawn + /// bottom → top. + pub ids: Vec, + /// Present only when the parent item's `type_flag == 1`: + /// `stored_cell_count` raw bytes. + pub extra_payload: Option>, } -/// A building/object made up of stacked BTL tileset tiles. +impl BundleEntry { + /// Anchor of this entry's tile stack in map-local pixels. + pub fn coords(&self) -> (i32, i32) { + (self.anchor_x, self.anchor_y) + } + + /// Bounding box `(left, top, right, bottom)` of this entry in map pixels. + pub fn bounds(&self) -> (i32, i32, i32, i32) { + ( + self.bound_x, + self.bound_y, + self.bound_right, + self.bound_bottom, + ) + } +} + +/// Two binary 0/1 flags for one stack level. +/// +/// There are `n = first_entry().grid_height` of these, one per stack level; +/// semantics not yet pinned. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StackLevelFlags { + pub flag_a: i32, + pub flag_b: i32, +} + +/// One record of the .map file's first block (the tiled-object refs). +/// +/// Both words are **constant across ALL shipped maps** — `word0 == 0` and +/// `word1 == 1` in all 99,104 records probed. The earlier "linear tile index" +/// interpretation was wrong; the true semantics are unknown. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TiledObjectRef { + /// Always 0 across all shipped maps; purpose unknown. + pub word0: i32, + /// Always 1 across all shipped maps; purpose unknown. + pub word1: i32, +} + +/// One mid-level node of the tiled-bundle tree. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BundleItem { + /// First i32 of the item. When `1`, every child entry carries an extra + /// payload array after its id list. Always 0 in all shipped maps. + pub type_flag: i32, + /// Third i32 of the item. It is 0 in all shipped maps. Its purpose is + /// unknown. + pub field_14: i32, + pub entries: Vec, +} + +/// One sub-record of a tiled bundle. #[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BundleRecord { + /// First i32 of the record. It is 0 in known maps. + pub field_04: i32, + // Binary metadata. It does not contain valid Windows-1250 or EUC-KR text. + #[serde(with = "bytes_array")] + pub body: [u8; 260], + pub items: Vec, +} + +/// A placed tiled building ("bundle") from the .map tiled-objects block. +/// +/// After the records, the first entry's `grid_height` gives the number of flag +/// pairs. The three end grids follow immediately. The format has no +/// end-of-block sentinel. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TiledBundle { + pub records: Vec, + /// One [`StackLevelFlags`] per stack level following the records + /// (`n = first_entry().grid_height`); two binary 0/1 flags each. + pub level_flags: Vec, +} + +impl TiledBundle { + /// First entry of the first record's first item, if any. + /// + /// This entry gives the number of flag pairs. + pub fn first_entry(&self) -> Option<&BundleEntry> { + self.records.first()?.items.first()?.entries.first() + } + + /// Derives the renderer-facing [`TiledObjectInfo`] from the tree. + /// + /// Mapping (verified byte-exact against the previous empirical parser on + /// every shipped map fixture): the anchor is the first entry's + /// `(anchor_x, anchor_y)` and the BTL tile stack is that entry's u16 + /// `ids`. + pub fn info(&self) -> Option { + let entry = self.first_entry()?; + let (x, y) = entry.coords(); + Some(TiledObjectInfo { + ids: entry.ids.iter().map(|&id| id as i16).collect(), + x, + y, + }) + } +} + +/// A building/object made up of stacked BTL tileset tiles. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TiledObjectInfo { pub ids: Vec, pub x: i32, diff --git a/src/map/writer.rs b/src/map/writer.rs index 402de6d1..bec9c58c 100644 --- a/src/map/writer.rs +++ b/src/map/writer.rs @@ -7,7 +7,9 @@ use super::types::Coords; /// Write map data back to a .map file by patching only the 3 end grids /// (event grid, tile & access grid, access-ref grid) in-place. All -/// header/sprite/object bytes are preserved unchanged. +/// header/sprite/object bytes are preserved unchanged — including the typed +/// tiled-bundle tree (`MapData::tiled_bundles`), which therefore survives a +/// write → re-parse cycle byte-identically. pub fn write_map_to_path(path: &Path, data: &MapData) -> io::Result<()> { let w = data.model.tiled_map_width; let h = data.model.tiled_map_height; @@ -201,7 +203,7 @@ mod tests { sprite_blocks: Vec::new(), internal_sprite_stamps: Vec::new(), tiled_object_refs: Vec::new(), - tiled_object_metadata: Vec::new(), + tiled_bundles: Vec::new(), }; let result = write_map_to_path(&tmp, &data); diff --git a/src/queries/create_table_map_bundle_entries.sql b/src/queries/create_table_map_bundle_entries.sql new file mode 100644 index 00000000..2650edf6 --- /dev/null +++ b/src/queries/create_table_map_bundle_entries.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS map_bundle_entries ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + record_index INTEGER NOT NULL, + item_index INTEGER NOT NULL, + entry_index INTEGER NOT NULL, + bound_x INTEGER NOT NULL, + bound_y INTEGER NOT NULL, + bound_right INTEGER NOT NULL, + bound_bottom INTEGER NOT NULL, + anchor_x INTEGER NOT NULL, + anchor_y INTEGER NOT NULL, + draw_x INTEGER NOT NULL, + draw_y INTEGER NOT NULL, + grid_width INTEGER NOT NULL, + grid_height INTEGER NOT NULL, + stored_cell_count INTEGER NOT NULL, + extra_bytes BLOB, + PRIMARY KEY (map_id, bundle_index, record_index, item_index, entry_index) +); diff --git a/src/queries/create_table_map_bundle_entry_ids.sql b/src/queries/create_table_map_bundle_entry_ids.sql new file mode 100644 index 00000000..d40c0754 --- /dev/null +++ b/src/queries/create_table_map_bundle_entry_ids.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS map_bundle_entry_ids ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + record_index INTEGER NOT NULL, + item_index INTEGER NOT NULL, + entry_index INTEGER NOT NULL, + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + PRIMARY KEY (map_id, bundle_index, record_index, item_index, entry_index, seq) +); diff --git a/src/queries/create_table_map_bundle_flags.sql b/src/queries/create_table_map_bundle_flags.sql new file mode 100644 index 00000000..410a24c0 --- /dev/null +++ b/src/queries/create_table_map_bundle_flags.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS map_bundle_flags ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + level_index INTEGER NOT NULL, + flag_a INTEGER NOT NULL, + flag_b INTEGER NOT NULL, + PRIMARY KEY (map_id, bundle_index, level_index) +); diff --git a/src/queries/create_table_map_bundle_items.sql b/src/queries/create_table_map_bundle_items.sql new file mode 100644 index 00000000..e2d8f7ed --- /dev/null +++ b/src/queries/create_table_map_bundle_items.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS map_bundle_items ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + record_index INTEGER NOT NULL, + item_index INTEGER NOT NULL, + type_flag INTEGER NOT NULL, + field_14 INTEGER NOT NULL, + entry_count INTEGER NOT NULL, + PRIMARY KEY (map_id, bundle_index, record_index, item_index) +); diff --git a/src/queries/create_table_map_bundle_records.sql b/src/queries/create_table_map_bundle_records.sql new file mode 100644 index 00000000..93eadaaa --- /dev/null +++ b/src/queries/create_table_map_bundle_records.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS map_bundle_records ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + record_index INTEGER NOT NULL, + field_04 INTEGER NOT NULL, + body BLOB NOT NULL, + item_count INTEGER NOT NULL, + PRIMARY KEY (map_id, bundle_index, record_index) +); diff --git a/src/queries/create_table_map_bundles.sql b/src/queries/create_table_map_bundles.sql new file mode 100644 index 00000000..862971e7 --- /dev/null +++ b/src/queries/create_table_map_bundles.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS map_bundles ( + map_id TEXT NOT NULL, + bundle_index INTEGER NOT NULL, + PRIMARY KEY (map_id, bundle_index) +); diff --git a/src/queries/create_table_map_object_metadata.sql b/src/queries/create_table_map_object_metadata.sql deleted file mode 100644 index 4ddf91ad..00000000 --- a/src/queries/create_table_map_object_metadata.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS map_object_metadata ( - map_id TEXT NOT NULL, - object_index INTEGER NOT NULL, - metadata_blob BLOB NOT NULL, - control_0 INTEGER NOT NULL, - control_1 INTEGER NOT NULL, - control_2 INTEGER NOT NULL, - control_3 INTEGER NOT NULL, - param_0 INTEGER NOT NULL, - param_1 INTEGER NOT NULL, - param_2 INTEGER NOT NULL, - param_3 INTEGER NOT NULL, - param_4 INTEGER NOT NULL, - param_5 INTEGER NOT NULL, - extra_count_a INTEGER NOT NULL, - extra_count_b INTEGER NOT NULL, - trailing_fixed BLOB NOT NULL, - trailing_variable BLOB NOT NULL, - PRIMARY KEY (map_id, object_index) -); diff --git a/src/queries/create_table_map_object_refs.sql b/src/queries/create_table_map_object_refs.sql index 54b5a372..a8e10219 100644 --- a/src/queries/create_table_map_object_refs.sql +++ b/src/queries/create_table_map_object_refs.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS map_object_refs ( map_id TEXT NOT NULL, ref_index INTEGER NOT NULL, - value0 INTEGER NOT NULL, - value1 INTEGER NOT NULL, + word0 INTEGER NOT NULL, + word1 INTEGER NOT NULL, PRIMARY KEY (map_id, ref_index) ); diff --git a/src/queries/insert_map_bundle.sql b/src/queries/insert_map_bundle.sql new file mode 100644 index 00000000..c64ebf54 --- /dev/null +++ b/src/queries/insert_map_bundle.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundles (map_id, bundle_index) +VALUES (?1, ?2); diff --git a/src/queries/insert_map_bundle_entry.sql b/src/queries/insert_map_bundle_entry.sql new file mode 100644 index 00000000..26b4d77a --- /dev/null +++ b/src/queries/insert_map_bundle_entry.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundle_entries (map_id, bundle_index, record_index, item_index, entry_index, bound_x, bound_y, bound_right, bound_bottom, anchor_x, anchor_y, draw_x, draw_y, grid_width, grid_height, stored_cell_count, extra_bytes) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17); diff --git a/src/queries/insert_map_bundle_entry_id.sql b/src/queries/insert_map_bundle_entry_id.sql new file mode 100644 index 00000000..bf27ed5b --- /dev/null +++ b/src/queries/insert_map_bundle_entry_id.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundle_entry_ids (map_id, bundle_index, record_index, item_index, entry_index, seq, id) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7); diff --git a/src/queries/insert_map_bundle_flag.sql b/src/queries/insert_map_bundle_flag.sql new file mode 100644 index 00000000..fbec9258 --- /dev/null +++ b/src/queries/insert_map_bundle_flag.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundle_flags (map_id, bundle_index, level_index, flag_a, flag_b) +VALUES (?1, ?2, ?3, ?4, ?5); diff --git a/src/queries/insert_map_bundle_item.sql b/src/queries/insert_map_bundle_item.sql new file mode 100644 index 00000000..c4da121e --- /dev/null +++ b/src/queries/insert_map_bundle_item.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundle_items (map_id, bundle_index, record_index, item_index, type_flag, field_14, entry_count) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7); diff --git a/src/queries/insert_map_bundle_record.sql b/src/queries/insert_map_bundle_record.sql new file mode 100644 index 00000000..3e8102cc --- /dev/null +++ b/src/queries/insert_map_bundle_record.sql @@ -0,0 +1,2 @@ +INSERT OR REPLACE INTO map_bundle_records (map_id, bundle_index, record_index, field_04, body, item_count) +VALUES (?1, ?2, ?3, ?4, ?5, ?6); diff --git a/src/queries/insert_map_object_metadata.sql b/src/queries/insert_map_object_metadata.sql deleted file mode 100644 index da1e4ec1..00000000 --- a/src/queries/insert_map_object_metadata.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT OR REPLACE INTO map_object_metadata (map_id, object_index, metadata_blob, control_0, control_1, control_2, control_3, param_0, param_1, param_2, param_3, param_4, param_5, extra_count_a, extra_count_b, trailing_fixed, trailing_variable) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17); diff --git a/src/queries/insert_map_object_ref.sql b/src/queries/insert_map_object_ref.sql index ad6b2f2e..21f91f29 100644 --- a/src/queries/insert_map_object_ref.sql +++ b/src/queries/insert_map_object_ref.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_object_refs (map_id, ref_index, value0, value1) +INSERT OR REPLACE INTO map_object_refs (map_id, ref_index, word0, word1) VALUES (?1, ?2, ?3, ?4); From 06a4cd6e32ee8d8ca8bfd5fb70fb11ad45b0c0f6 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:20:54 +0200 Subject: [PATCH 37/49] Add feather and rune stone teleport marks data in misc item --- .../src/editors/save_file_viewer/update/mod.rs | 2 +- src/references/save_file/inventory.rs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dispel-gui/src/editors/save_file_viewer/update/mod.rs b/dispel-gui/src/editors/save_file_viewer/update/mod.rs index ca6212cd..638249fb 100644 --- a/dispel-gui/src/editors/save_file_viewer/update/mod.rs +++ b/dispel-gui/src/editors/save_file_viewer/update/mod.rs @@ -1292,7 +1292,7 @@ pub fn handle(msg: SaveFileViewerMessage, app: &mut App) -> Task { item.name.clone(), item.description.clone(), item.base_price.to_string(), - hex_bytes(&item.reserved_definition_bytes), + hex_bytes(&item.reserved_mark_tail_bytes), item.misc_item_id.to_string(), item.item_category.to_string(), item.inventory_record_index.to_string(), diff --git a/src/references/save_file/inventory.rs b/src/references/save_file/inventory.rs index ce715181..5e8a2aa9 100644 --- a/src/references/save_file/inventory.rs +++ b/src/references/save_file/inventory.rs @@ -263,9 +263,15 @@ pub struct InventoryMiscItem { #[binary_record(string(encoding = "WINDOWS-1250", size = 202))] pub description: String, pub base_price: u32, - /// Reserved bytes copied from the item definition; no runtime use is known. - #[binary_record(size = 16)] - pub reserved_definition_bytes: Vec, + /// X coordinate of a Rune Stone mark. Zero when no mark is stored. + pub rune_mark_x: i32, + /// Y coordinate of a Rune Stone mark. Zero when no mark is stored. + pub rune_mark_y: i32, + /// Map ID of a Rune Stone mark. Zero when no mark is stored. + pub rune_mark_map_id: u32, + /// Reserved bytes after the mark data. They are zero in known saves. + #[binary_record(size = 4)] + pub reserved_mark_tail_bytes: Vec, /// Zero-based index of the corresponding miscellaneous-item definition. pub misc_item_id: u32, /// Zero-based inventory category: `0`=weapon, `1`=heal, `2`=edit, `3`=misc, `4`=event. From ec9dfa6ea66cc10f6339fd951bffa0659c746d33 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:32:42 +0200 Subject: [PATCH 38/49] Add proc-macro extractor for a nested structure --- dispel-macros/src/extractor_impl.rs | 123 +++++++++++++++++++++++ dispel-macros/src/layout_impl.rs | 11 ++ dispel-macros/src/record_patcher_impl.rs | 8 ++ src/references/extractor.rs | 26 +++++ 4 files changed, 168 insertions(+) diff --git a/dispel-macros/src/extractor_impl.rs b/dispel-macros/src/extractor_impl.rs index 753bf7e9..5d9b6430 100644 --- a/dispel-macros/src/extractor_impl.rs +++ b/dispel-macros/src/extractor_impl.rs @@ -165,6 +165,30 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { }); struct_field_inits.push(quote! { #ident: #ident, }); } + FieldInfo::Nested { + ident, + item_ty, + max_items, + item_size: _, + } => { + let inner_ty = extract_vec_inner(item_ty).unwrap_or_else(|| { + panic!( + "#[extractor(nested)] requires the field type to be Vec; got `{}`", + quote! { #item_ty } + ) + }); + let inner_ty_tokens = quote! { #inner_ty }; + parse_stmts.push(quote! { + let mut #ident: Vec<#inner_ty_tokens> = Vec::with_capacity(#max_items); + for _ in 0..#max_items { + match <#inner_ty_tokens as crate::references::extractor::Extractor>::parse_one(reader)? { + Some(item) => #ident.push(item), + None => break, + } + } + }); + struct_field_inits.push(quote! { #ident: #ident, }); + } FieldInfo::InventoryItem { ident, wire_type } => { let read_stmt = match wire_type.as_str() { "u16" => { @@ -317,6 +341,34 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { writer.write_all(&record.#ident)?; }); } + FieldInfo::Nested { + ident, + item_ty, + max_items, + item_size, + } => { + let inner_ty = extract_vec_inner(item_ty).unwrap_or_else(|| { + panic!( + "#[extractor(nested)] requires the field type to be Vec; got `{}`", + quote! { #item_ty } + ) + }); + let inner_ty_tokens = quote! { #inner_ty }; + let total_size = *max_items as i32 * *item_size; + write_stmts.push(quote! { + for item in record.#ident.iter() { + <#inner_ty_tokens as crate::references::extractor::Extractor>::write_one(item, writer)?; + } + let written = (record.#ident.len() as i32) * (#item_size as i32); + let remaining_slots = ((#total_size as i32 - written) / #item_size as i32) as usize; + if remaining_slots > 0 { + let pad = vec![0u8; #item_size as usize]; + for _ in 0..remaining_slots { + writer.write_all(&pad)?; + } + } + }); + } FieldInfo::InventoryItem { ident, wire_type } => { let write_stmt = match wire_type.as_str() { "u16" => { @@ -372,6 +424,8 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { // Generate the full implementation let expanded = quote! { impl crate::references::extractor::Extractor for #name { + const ITEM_SIZE: i32 = #property_item_size; + fn parse(reader: &mut R, len: u64) -> std::io::Result> { const COUNTER_SIZE: u8 = #counter_size; const PROPERTY_ITEM_SIZE: i32 = #property_item_size; @@ -406,6 +460,22 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { Ok(()) } + + fn parse_one(reader: &mut R) -> std::io::Result> { + const ITEM_SIZE: usize = #property_item_size as usize; + let mut buf = vec![0u8; ITEM_SIZE]; + reader.read_exact(&mut buf)?; + if buf.iter().all(|&b| b == 0) { + return Ok(None); + } + let mut cursor = std::io::Cursor::new(buf); + let mut parsed = Self::parse(&mut cursor, ITEM_SIZE as u64)?; + Ok(parsed.pop()) + } + + fn write_one(record: &Self, writer: &mut W) -> std::io::Result<()> { + Self::to_writer(std::slice::from_ref(record), writer) + } } }; @@ -467,6 +537,12 @@ pub(crate) enum FieldInfo<'a> { ident: &'a Ident, size: usize, }, + Nested { + ident: &'a Ident, + item_ty: &'a Type, + max_items: usize, + item_size: i32, + }, Skip, } @@ -482,6 +558,8 @@ pub(crate) fn parse_extractor_attr<'a>( let mut field_info = None; let mut counter_size = None; let mut property_item_size = None; + let mut nested_max_items: Option = None; + let mut nested_item_size: Option = None; attr.parse_nested_meta(|meta| { if meta.path.is_ident("id") { @@ -647,6 +725,21 @@ pub(crate) fn parse_extractor_attr<'a>( })?; let size = size.expect("vec_u8 requires size"); field_info = Some(FieldInfo::VecU8 { ident, size }); + } else if meta.path.is_ident("nested") { + // `nested` is a flag (no parens). The `max_items` and `item_size` + // keys are expected as additional top-level metas on the same + // attribute (handled below). + } else if meta.path.is_ident("max_items") { + let value = meta.value()?; + let lit: LitInt = value.parse()?; + nested_max_items = Some( + lit.base10_parse::() + .expect("max_items must be usize"), + ); + } else if meta.path.is_ident("item_size") { + let value = meta.value()?; + let lit: LitInt = value.parse()?; + nested_item_size = Some(lit.base10_parse::().expect("item_size must be i32")); } else if meta.path.is_ident("skip") { field_info = Some(FieldInfo::Skip); } else if meta.path.is_ident("inventory_item") { @@ -678,6 +771,15 @@ pub(crate) fn parse_extractor_attr<'a>( }) .expect("Failed to parse #[extractor(...)] arguments"); + if let (Some(max_items), Some(item_size)) = (nested_max_items, nested_item_size) { + field_info = Some(FieldInfo::Nested { + ident, + item_ty: _ty, + max_items, + item_size, + }); + } + (field_info, counter_size, property_item_size) } @@ -692,3 +794,24 @@ pub(crate) fn get_encoding_tokens(encoding: &str) -> TokenStream2 { ), } } + +/// Extract the inner `T` from a field declared as `Vec`. +/// Returns `None` if the type is not a `Vec<...>` (path with that ident and one generic arg). +pub(crate) fn extract_vec_inner(ty: &Type) -> Option<&Type> { + let Type::Path(type_path) = ty else { + return None; + }; + let last = type_path.path.segments.last()?; + if last.ident != "Vec" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + let inner = args.args.first()?; + if let syn::GenericArgument::Type(inner_ty) = inner { + Some(inner_ty) + } else { + None + } +} diff --git a/dispel-macros/src/layout_impl.rs b/dispel-macros/src/layout_impl.rs index cd11c826..1909c47d 100644 --- a/dispel-macros/src/layout_impl.rs +++ b/dispel-macros/src/layout_impl.rs @@ -102,5 +102,16 @@ fn field_size_and_type(info: &FieldInfo<'_>) -> Option<(u32, String)> { FieldInfo::Array { size, ty, .. } => { Some(((*size as u32) * scalar(ty), format!("[{ty}; {size}]"))) } + FieldInfo::Nested { + item_size, + max_items, + .. + } => { + // Nested array/record — approximate with fixed item size + Some(( + (*max_items as u32) * (*item_size as u32), + format!("Nested(max={max_items}, item_size={item_size})"), + )) + } } } diff --git a/dispel-macros/src/record_patcher_impl.rs b/dispel-macros/src/record_patcher_impl.rs index 4a569f1a..52dbba3a 100644 --- a/dispel-macros/src/record_patcher_impl.rs +++ b/dispel-macros/src/record_patcher_impl.rs @@ -180,6 +180,14 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { ))); } }, + FieldInfo::Nested { ident: _, .. } => quote! { + #field_name => { + return Err(crate::modding::error::ModdingError::Malformed(format!( + "{}.{} is nested and cannot be patched", + Self::RECORD_NAME, #field_name + ))); + } + }, FieldInfo::Skip => continue, }; field_arms.push(arm); diff --git a/src/references/extractor.rs b/src/references/extractor.rs index 8845fc95..f029746f 100644 --- a/src/references/extractor.rs +++ b/src/references/extractor.rs @@ -10,6 +10,32 @@ pub trait Extractor: Sized { fn to_writer(records: &[Self], writer: &mut W) -> std::io::Result<()>; + /// Read exactly one record from `reader`. Returns `Ok(None)` when the slot + /// is a terminator (e.g. all-zero sentinel) — callers (typically a parent + /// `#[extractor(nested, max_items = N, item_size = M)]` field) use this + /// to bound a variable-length list embedded inside a fixed-size block. + /// + /// `#[derive(Extractor)]` generates an implementation backed by `ITEM_SIZE` + /// and the type's regular `parse` path. Manually implemented `Extractor`s + /// that need to participate in nested lists must override this method. + fn parse_one(_reader: &mut R) -> std::io::Result> { + unimplemented!("Extractor::parse_one must be implemented for nested list support") + } + + /// Write exactly one record to `writer`. No length prefix, no padding. + /// + /// `#[derive(Extractor)]` generates an implementation that delegates to + /// `to_writer` for a single record. Override only if you need custom + /// per-record framing. + fn write_one(_record: &Self, _writer: &mut W) -> std::io::Result<()> { + unimplemented!("Extractor::write_one must be implemented for nested list support") + } + + /// Fixed byte size of one record. `#[derive(Extractor)]` fills this in + /// from `property_item_size`; manually implemented `Extractor`s should + /// override it when participating in nested lists. + const ITEM_SIZE: i32 = 0; + fn read_file(path: &Path) -> std::io::Result> { let file = File::open(path)?; let len = file.metadata()?.len(); From d2a997d618491ced3c5da1c686ab5d16921dd62e Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:04:33 +0200 Subject: [PATCH 39/49] Render sprite animation films with no origin offset --- docs/files/Sprites.spr.md | 5 ++++- src/cli.rs | 13 ++++--------- src/commands/sprite.rs | 3 ++- src/main.rs | 8 +++++++- src/sprite.rs | 31 +++++++++++++++++++++++++++---- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/files/Sprites.spr.md b/docs/files/Sprites.spr.md index b8328d7e..cf637285 100644 --- a/docs/files/Sprites.spr.md +++ b/docs/files/Sprites.spr.md @@ -203,9 +203,12 @@ The file is parsed by the `src/sprite.rs` module. # Extract individual frames as separate PNG files cargo run -- sprite "path/to/file.spr" -# Extract animation sequences as horizontal strip PNGs +# Extract animation sequences as horizontal strip PNGs, aligned by origin cargo run -- sprite "path/to/file.spr" --mode animation +# Extract animation sequences without applying origin offsets +cargo run -- sprite "path/to/file.spr" --mode animation --no-origin-offset + # Output sprite metadata as JSON (no rendering) cargo run -- sprite "path/to/file.spr" --info ``` diff --git a/src/cli.rs b/src/cli.rs index fb095fc9..3aad060c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -83,17 +83,12 @@ pub enum Commands { Sprite { /// Path to the source .SPR file input: String, - #[arg( - long, - require_equals = true, - value_name = "MODE", - num_args = 0..=1, - default_value_t = SpriteMode::Sprite, - default_missing_value = "always", - value_enum - )] + #[arg(long, value_name = "MODE", default_value_t = SpriteMode::Sprite, value_enum)] /// Mode: 'sprite' (individual frames) or 'animation' (full sequence) mode: SpriteMode, + /// Render animation frames at their raw top-left position instead of aligning their origins + #[arg(long)] + no_origin_offset: bool, /// Output sprite metadata as JSON (no rendering) #[arg(long)] info: bool, diff --git a/src/commands/sprite.rs b/src/commands/sprite.rs index 34b1fdc9..a6413a62 100644 --- a/src/commands/sprite.rs +++ b/src/commands/sprite.rs @@ -8,6 +8,7 @@ use std::path::Path; pub struct SpriteCommand { pub input: String, pub mode: CliSpriteMode, + pub no_origin_offset: bool, pub info: bool, } @@ -38,7 +39,7 @@ impl Command for SpriteCommand { .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; } SpriteMode::Animation => { - sprite::animation(Path::new(&self.input)) + sprite::animation(Path::new(&self.input), !self.no_origin_offset) .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; } } diff --git a/src/main.rs b/src/main.rs index 4e30dc29..678a2c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,9 +26,15 @@ fn main() { Some(Commands::List(args)) => ListCommand { args: args.clone() }.execute(), Some(Commands::Schema(args)) => SchemaCommand { args: args.clone() }.execute(), Some(Commands::Template(args)) => TemplateCommand { args: args.clone() }.execute(), - Some(Commands::Sprite { input, mode, info }) => SpriteCommand { + Some(Commands::Sprite { + input, + mode, + no_origin_offset, + info, + }) => SpriteCommand { input: input.clone(), mode: *mode, + no_origin_offset: *no_origin_offset, info: *info, } .execute(), diff --git a/src/sprite.rs b/src/sprite.rs index 80fedf26..1e7c4fac 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -388,9 +388,18 @@ where // CLI commands (file extraction) // =========================================================================== -pub fn animation(file_path: &Path) -> Result<()> { +/// Exports every sequence as a horizontal animation atlas. +/// +/// When `apply_origin_offsets` is true, each frame is placed in the shared +/// sequence bounds so its origin remains fixed across the animation. +pub fn animation(file_path: &Path, apply_origin_offsets: bool) -> Result<()> { for_each_sequence(file_path, |reader, info, seq_idx| { - save_sequence_anim(reader, &info.frame_infos, seq_idx as i32) + save_sequence_anim( + reader, + &info.frame_infos, + seq_idx as i32, + apply_origin_offsets, + ) })?; println!("Finished"); Ok(()) @@ -408,6 +417,7 @@ pub fn save_sequence_anim( reader: &mut BufReader, frames: &[ImageInfo], sequence_counter: i32, + apply_origin_offsets: bool, ) -> Result<()> { println!("Frames: {:?}, Sequence: {sequence_counter}", frames.len()); @@ -417,7 +427,15 @@ pub fn save_sequence_anim( println!("x:{rect_x} y:{rect_y} w:{rect_w} h:{rect_h}"); - let imgbuf = render_sequence_animation(reader, frames, rect_x, rect_y, rect_w, rect_h)?; + let imgbuf = render_sequence_animation( + reader, + frames, + rect_x, + rect_y, + rect_w, + rect_h, + apply_origin_offsets, + )?; imgbuf .save(format!("image_{:?}.png", sequence_counter)) @@ -437,12 +455,17 @@ fn render_sequence_animation( rect_y: i32, rect_w: u32, rect_h: u32, + apply_origin_offsets: bool, ) -> Result { let atlas_w = rect_w * (frames.len() as u32); let mut imgbuf = RgbaImage::new(atlas_w, rect_h); for (i, frame) in frames.iter().enumerate() { - let (offset_x, offset_y) = compute_frame_offset(frames, i, rect_x, rect_y); + let (offset_x, offset_y) = if apply_origin_offsets { + compute_frame_offset(frames, i, rect_x, rect_y) + } else { + (0, 0) + }; let frame_rgba = render_frame_to_rgba(reader, frame, rect_w, rect_h, offset_x, offset_y)?; for (px, py, pixel) in frame_rgba.enumerate_pixels() { From 37d605ae3a53adea1427c24aa1ffc1d15ef9f889 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:09:17 +0200 Subject: [PATCH 40/49] Add multi file parsers --- dispel-gui/src/tests/map_editor.rs | 59 +++++ src/commands/registry/detect.rs | 5 + src/commands/registry/entries.rs | 52 ++++ src/database.rs | 8 + src/lib.rs | 4 + src/queries/create_table_chest_spawns.sql | 9 + src/queries/create_table_map_info_multi.sql | 15 ++ src/queries/create_table_monster_spawns.sql | 11 + src/queries/create_table_tile_grids.sql | 7 + src/queries/insert_chest_spawns.sql | 1 + src/queries/insert_map_info_multi.sql | 1 + src/queries/insert_monster_spawns.sql | 1 + src/queries/insert_tile_grids.sql | 1 + src/references/chest_spawn.rs | 114 +++++++++ src/references/map_info_multi.rs | 257 ++++++++++++++++++++ src/references/mod.rs | 4 + src/references/monster_spawn.rs | 116 +++++++++ src/references/tile_grid.rs | 235 ++++++++++++++++++ 18 files changed, 900 insertions(+) create mode 100644 src/queries/create_table_chest_spawns.sql create mode 100644 src/queries/create_table_map_info_multi.sql create mode 100644 src/queries/create_table_monster_spawns.sql create mode 100644 src/queries/create_table_tile_grids.sql create mode 100644 src/queries/insert_chest_spawns.sql create mode 100644 src/queries/insert_map_info_multi.sql create mode 100644 src/queries/insert_monster_spawns.sql create mode 100644 src/queries/insert_tile_grids.sql create mode 100644 src/references/chest_spawn.rs create mode 100644 src/references/map_info_multi.rs create mode 100644 src/references/monster_spawn.rs create mode 100644 src/references/tile_grid.rs diff --git a/dispel-gui/src/tests/map_editor.rs b/dispel-gui/src/tests/map_editor.rs index 476c371c..e8970604 100644 --- a/dispel-gui/src/tests/map_editor.rs +++ b/dispel-gui/src/tests/map_editor.rs @@ -699,4 +699,63 @@ mod map_editor_entity_tests { "MapRenderSource::shadow_data surfaces loaded tables to the canvas" ); } + + // ── CLI ↔ GUI placement parity ───────────────────────────────────────── + + /// The GUI draws entities in absolute canvas space; the CLI crops to the + /// occluded viewport. Both must agree: cli_pos = gui_world_pos − origin. + #[test] + fn gui_tile_world_center_matches_core_transform() { + use crate::components::map_render::{TILE_H, TILE_W, tile_world_center}; + + let model = dispel_core::map::model::MapModel { + tiled_map_width: 249, + tiled_map_height: 249, + ..Default::default() + }; + let diagonal = model.tiled_map_width + model.tiled_map_height; + for (x, y) in [(0i32, 0i32), (10, 7), (67, 35), (140, 93), (248, 248)] { + let (wx, wy) = tile_world_center(x, y, diagonal); + let (px, py) = + dispel_core::map::types::convert_map_coords_to_image_coords(x, y, diagonal); + assert_eq!(wx, px as f32 + TILE_W * 0.5); + assert_eq!(wy, py as f32 + TILE_H * 0.5); + + // The CLI's viewport-space anchor is the same point minus the + // non-occluded origin. + let cli_cx = px - model.map_non_occluded_start_x + + dispel_core::map::tileset::TILE_WIDTH as i32 / 2; + let cli_cy = py - model.map_non_occluded_start_y + + dispel_core::map::tileset::TILE_HEIGHT as i32 / 2; + assert_eq!( + wx as i32 - model.map_non_occluded_start_x, + cli_cx, + "GUI/CLI x placement diverged at ({x},{y})" + ); + assert_eq!( + wy as i32 - model.map_non_occluded_start_y, + cli_cy, + "GUI/CLI y placement diverged at ({x},{y})" + ); + } + } + + /// GUI hit-testing and the CLI renderer must pick the same standing spot + /// for every NPC record in the fixture set. + #[test] + fn gui_npc_placement_matches_core_renderer() { + use crate::editors::map_editor::canvas::hit_test::npc_pos; + let gp = std::path::PathBuf::from("../fixtures/Dispel"); + if !gp.exists() { + eprintln!("Skipping: fixtures not found"); + return; + } + use dispel_core::references::extractor::Extractor; + let npcs = dispel_core::NPC::read_file(&gp.join("NpcInGame/Npccat1.ref")) + .expect("parse npccat1.ref"); + assert!(!npcs.is_empty()); + for n in &npcs { + assert_eq!(npc_pos(n), dispel_core::map::render::npc_placement(n)); + } + } } diff --git a/src/commands/registry/detect.rs b/src/commands/registry/detect.rs index c9be5e9e..d0087581 100644 --- a/src/commands/registry/detect.rs +++ b/src/commands/registry/detect.rs @@ -47,6 +47,11 @@ pub static FILE_TYPES: LazyLock> = LazyLock::new(|| { entries::make_btl(), // Sprite files (extract only, no patch) entries::make_sprite(), + // Multiplayer file types + entries::make_chest_spawn(), + entries::make_monster_spawn(), + entries::make_map_info_multi(), + entries::make_tile_grid(), // Save files entries::make_save_file(), entries::make_save_ifo(), diff --git a/src/commands/registry/entries.rs b/src/commands/registry/entries.rs index 8a48bb19..c79896c3 100644 --- a/src/commands/registry/entries.rs +++ b/src/commands/registry/entries.rs @@ -447,6 +447,58 @@ pub(crate) fn make_sprite() -> FileType { } } +pub(crate) fn make_chest_spawn() -> FileType { + FileType { + key: "chest_spawn", + name: "*.cst", + description: "Multiplayer chest/placeable spawn list (CSV, EUC-KR)", + extensions: &[".cst"], + detect_kind: DetectKind::Db(&[]), + extract_fn: extract_as::, + patch_fn: patch_as::, + validate_fn: Some(validate_as::), + } +} + +pub(crate) fn make_monster_spawn() -> FileType { + FileType { + key: "monster_spawn", + name: "*.mon", + description: "Multiplayer monster spawn list (CSV, EUC-KR)", + extensions: &[".mon"], + detect_kind: DetectKind::Db(&[]), + extract_fn: extract_as::, + patch_fn: patch_as::, + validate_fn: Some(validate_as::), + } +} + +pub(crate) fn make_map_info_multi() -> FileType { + FileType { + key: "map_info_multi", + name: "*.ifm", + description: "Multiplayer map info/linkage struct (binary, 100 B)", + extensions: &[".ifm"], + detect_kind: DetectKind::Db(&[]), + extract_fn: extract_as::, + patch_fn: patch_as::, + validate_fn: None, + } +} + +pub(crate) fn make_tile_grid() -> FileType { + FileType { + key: "tile_grid", + name: "*.dat", + description: "Multiplayer per-tile u32 grid (binary, 249×249)", + extensions: &[".dat"], + detect_kind: DetectKind::Db(&[]), + extract_fn: extract_as::, + patch_fn: patch_as::, + validate_fn: None, + } +} + pub(crate) fn make_save_file() -> FileType { FileType { key: "save_file", diff --git a/src/database.rs b/src/database.rs index 54df1a0b..32cc6d83 100644 --- a/src/database.rs +++ b/src/database.rs @@ -15,6 +15,10 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { let tables = vec![ "ch_datas", + "chest_spawns", + "monster_spawns", + "map_info_multi", + "tile_grids", "dialogue_paragraphs", "dialogue_script_files", "dialogue_scripts", @@ -137,6 +141,10 @@ pub fn initialize_database(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("queries/create_table_sprite_sequences.sql"))?; conn.execute_batch(include_str!("queries/create_table_event_actions.sql"))?; conn.execute_batch(include_str!("queries/create_table_chdata.sql"))?; + conn.execute_batch(include_str!("queries/create_table_chest_spawns.sql"))?; + conn.execute_batch(include_str!("queries/create_table_monster_spawns.sql"))?; + conn.execute_batch(include_str!("queries/create_table_map_info_multi.sql"))?; + conn.execute_batch(include_str!("queries/create_table_tile_grids.sql"))?; // Re-enable foreign key enforcement now that the schema is rebuilt. conn.execute_batch("PRAGMA foreign_keys = ON;")?; diff --git a/src/lib.rs b/src/lib.rs index 286d7e79..b0c2d0e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub use localization::{ pub use references::{ all_map_ini::Map, chdata_db::ChData, + chest_spawn::ChestSpawn, dialogue_paragraph::DialogueParagraph, dialogue_script::DialogueScript, draw_item::DrawItem, @@ -36,12 +37,14 @@ pub use references::{ extractor::Extractor, heal_item_db::HealItem, magic_db::MagicSpell, + map_info_multi::{IfmNpcSpawn, MapInfoMulti}, map_ini::MapIni, message_scr::Message, misc_item_db::MiscItem, monster_db::Monster, monster_ini::MonsterIni, monster_ref::MonsterRef, + monster_spawn::MonsterSpawn, npc_ini::NpcIni, npc_ref::NPC, party_ini_db::PartyIniNpc, @@ -54,6 +57,7 @@ pub use references::{ }, save_ifo::{SavTail, SaveIfo, SaveSlotInfo, SlotSummary, summarize_slots, swap_slots}, store_db::Store, + tile_grid::TileGrid, wave_ini::WaveIni, weapons_db::WeaponItem, }; diff --git a/src/queries/create_table_chest_spawns.sql b/src/queries/create_table_chest_spawns.sql new file mode 100644 index 00000000..6996ca0d --- /dev/null +++ b/src/queries/create_table_chest_spawns.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS chest_spawns +( + serial INTEGER NOT NULL, + x INTEGER, + y INTEGER, + direction INTEGER, + item_group INTEGER, + gold_class INTEGER +); diff --git a/src/queries/create_table_map_info_multi.sql b/src/queries/create_table_map_info_multi.sql new file mode 100644 index 00000000..ec188963 --- /dev/null +++ b/src/queries/create_table_map_info_multi.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS map_info_multi +( + id INTEGER PRIMARY KEY, + self_name TEXT, + anchor_tile_index INTEGER, + stride INTEGER, + row_step INTEGER, + start_pos_a_x INTEGER, + start_pos_a_y INTEGER, + start_pos_b_x INTEGER, + start_pos_b_y INTEGER, + transition_target TEXT, + mon_filename TEXT, + npc_spawns_json BLOB +); diff --git a/src/queries/create_table_monster_spawns.sql b/src/queries/create_table_monster_spawns.sql new file mode 100644 index 00000000..918ca971 --- /dev/null +++ b/src/queries/create_table_monster_spawns.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS monster_spawns +( + serial INTEGER NOT NULL, + monster_no INTEGER, + x INTEGER, + y INTEGER, + persistent INTEGER, + burrow INTEGER, + item_group INTEGER, + drop_attribute INTEGER +); diff --git a/src/queries/create_table_tile_grids.sql b/src/queries/create_table_tile_grids.sql new file mode 100644 index 00000000..be7585c1 --- /dev/null +++ b/src/queries/create_table_tile_grids.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS tile_grids +( + id INTEGER PRIMARY KEY, + payload_length INTEGER, + grid_size INTEGER, + data_json BLOB +); diff --git a/src/queries/insert_chest_spawns.sql b/src/queries/insert_chest_spawns.sql new file mode 100644 index 00000000..27ee0e8d --- /dev/null +++ b/src/queries/insert_chest_spawns.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO chest_spawns (serial, x, y, direction, item_group, gold_class) VALUES (?1, ?2, ?3, ?4, ?5, ?6); diff --git a/src/queries/insert_map_info_multi.sql b/src/queries/insert_map_info_multi.sql new file mode 100644 index 00000000..cd5fc5dd --- /dev/null +++ b/src/queries/insert_map_info_multi.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO map_info_multi (id, self_name, anchor_tile_index, stride, row_step, start_pos_a_x, start_pos_a_y, start_pos_b_x, start_pos_b_y, transition_target, mon_filename, npc_spawns_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12); diff --git a/src/queries/insert_monster_spawns.sql b/src/queries/insert_monster_spawns.sql new file mode 100644 index 00000000..9fb5ef4e --- /dev/null +++ b/src/queries/insert_monster_spawns.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO monster_spawns (serial, monster_no, x, y, persistent, burrow, item_group, drop_attribute) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); diff --git a/src/queries/insert_tile_grids.sql b/src/queries/insert_tile_grids.sql new file mode 100644 index 00000000..5673d2fc --- /dev/null +++ b/src/queries/insert_tile_grids.sql @@ -0,0 +1 @@ +INSERT OR IGNORE INTO tile_grids (id, payload_length, grid_size, data_json) VALUES (?1, ?2, ?3, ?4); diff --git a/src/references/chest_spawn.rs b/src/references/chest_spawn.rs new file mode 100644 index 00000000..5d6d599d --- /dev/null +++ b/src/references/chest_spawn.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use crate::references::extractor::Extractor; +use dispel_macros::{TextExtractor, TextRecordPatcher}; +use rusqlite::{Connection, Result, params}; +use serde::{Deserialize, Serialize}; + +/// `Map/Multi/*.cst` — chest/placeable spawn list (multiplayer). +/// +/// Plain-text CSV, EUC-KR encoded. Lines starting with `;` are skipped as +/// comments (the first row is a Korean column header). CRLF line endings. +/// +/// Record layout (6 columns): +/// - `serial`: serial number +/// - `x`: tile X coordinate +/// - `y`: tile Y coordinate +/// - `direction`: facing direction +/// - `item_group`: item drop group number +/// - `gold_class`: gold drop class number (0=none, 1=1-20, 2=21-50, ... 6=501-1000) +/// +#[derive(Debug, Clone, Default, Serialize, Deserialize, TextExtractor, TextRecordPatcher)] +#[extractor(encoding = "EUC_KR", delimiter = ",", comment_char = ";")] +#[patcher(filename = "*.cst")] +pub struct ChestSpawn { + /// Serial number. + #[extractor(field = 0)] + pub serial: i32, + /// Tile X coordinate. + #[extractor(field = 1)] + pub x: i32, + /// Tile Y coordinate. + #[extractor(field = 2)] + pub y: i32, + /// Facing direction (0-7 compass orientation, same scale as `NpcLookingDirection`). + #[extractor(field = 3)] + pub direction: i32, + /// Item drop group index. + #[extractor(field = 4)] + pub item_group: i32, + /// Gold class (0=none, 1..6=ascending ranges). + /// + /// Verified ranges: 0: none · 1: 1-20 · 2: 21-50 · 3: 51-100 · + /// 4: 101-200 · 5: 201-500 · 6: 501-1000. + #[extractor(field = 5)] + pub gold_class: i32, +} + +pub fn read_chest_spawn(source_path: &Path) -> std::io::Result> { + ChestSpawn::read_file(source_path) +} + +pub fn save_chest_spawns(conn: &mut Connection, spawns: &[ChestSpawn]) -> Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO chest_spawns (serial, x, y, direction, item_group, gold_class) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + )?; + for spawn in spawns { + stmt.execute(params![ + spawn.serial, + spawn.x, + spawn.y, + spawn.direction, + spawn.item_group, + spawn.gold_class, + ])?; + } + } + tx.commit()?; + Ok(()) +} + +impl std::fmt::Display for ChestSpawn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ChestSpawn(serial={}, x={}, y={}, dir={}, group={}, gold={})", + self.serial, self.x, self.y, self.direction, self.item_group, self.gold_class + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn parse_cst_sample() { + let data = b"; \xb1\xd5\xb1\xc0,\xc2\xc0\xc6\xc5 X ,\xc2\xc0\xc6\xc5 Y,\xb9\xe6\xc7\xc5(\xc1\xf6\xc0\xfc\xc0\xcc 1),\xc0\xc7\xc0\xcd\xb8\xc7 \xb1\xe6\xc7\xc5 \xb9\xf8\xc8\xa3,\xb1\xfb\xc0\xc7 \xba\xd0\xb7\xc7 \xb9\xf8\xc8\xa3\r\n0,211,84,0,1,0\r\n1,207,109,0,1,0\r\n"; + let mut c = Cursor::new(data.as_ref()); + let spawns = ChestSpawn::parse(&mut c, data.len() as u64).unwrap(); + assert!(!spawns.is_empty()); + assert_eq!(spawns[0].serial, 0); + assert_eq!(spawns[0].x, 211); + assert_eq!(spawns[0].y, 84); + assert_eq!(spawns[0].direction, 0); + assert_eq!(spawns[0].item_group, 1); + assert_eq!(spawns[0].gold_class, 0); + } + + #[test] + fn serialize_round_trip() { + let data = b"0,211,84,0,1,0\r\n"; + let mut c = Cursor::new(data.as_ref()); + let records = ChestSpawn::parse(&mut c, data.len() as u64).unwrap(); + let mut out = Vec::new(); + ChestSpawn::to_writer(&records, &mut out).unwrap(); + let mut c2 = Cursor::new(out.as_slice()); + let records2 = ChestSpawn::parse(&mut c2, out.len() as u64).unwrap(); + assert_eq!(records.len(), records2.len()); + assert_eq!(records[0].serial, records2[0].serial); + } +} diff --git a/src/references/map_info_multi.rs b/src/references/map_info_multi.rs new file mode 100644 index 00000000..7acc37f0 --- /dev/null +++ b/src/references/map_info_multi.rs @@ -0,0 +1,257 @@ +use std::io::{Read, Seek, Write}; +use std::path::Path; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use rusqlite::{Connection, Result, params}; +use serde::{Deserialize, Serialize}; + +use crate::references::extractor::Extractor; + +/// NPC spawn entry within the `.ifm` file. +/// +/// Each entry has 12 bytes: `{u32 x, u32 y, u8 kind}` and three padding bytes. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct IfmNpcSpawn { + /// Tile X coordinate. + pub x: u32, + /// Tile Y coordinate. + pub y: u32, + /// NPC kind or grid ID. + pub kind: u8, +} + +/// `Map/Multi/*.ifm` — map info / linkage struct (multiplayer). +/// +/// The file contains one 100-byte record. +/// +/// Layout (file offsets 0x00–0x63): +/// +/// | Offset | Type | Field | +/// |---|---|---| +/// | +0x00 | `char[8]` | Self map name, NUL-padded | +/// | +0x08 | `u32` | Anchor tile index (=149 conventionally) | +/// | +0x0C | `u32` | Stride | +/// | +0x10 | `u32` | Row-step offset (=248) | +/// | +0x14 | `u32` | Start-pos A X | +/// | +0x18 | `u32` | Start-pos A Y | +/// | +0x1C | `u32` | Start-pos B X | +/// | +0x20 | `u32` | Start-pos B Y | +/// | +0x24 | `char[6]` + 2 B pad | Transition target name | +/// | +0x2C | `char[6]` + 2 B pad | `.mon` file name (self name in practice) | +/// | +0x34 | 4 × (`u32 x`, `u32 y`, `u8 kind`, 3 B pad) | NPC spawn table | +/// +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct MapInfoMulti { + /// Zero-based logical index derived from parse order (not on disk). + pub id: i32, + /// Self map name (e.g. `"QS1"`, `"QD1-1"`), NUL-padded to 8 bytes. + pub self_name: String, + /// Anchor tile index. + pub anchor_tile_index: u32, + /// Map stride value. + pub stride: u32, + /// Row-step value. + pub row_step: u32, + /// First start position pair. + pub start_pos_a_x: u32, + pub start_pos_a_y: u32, + /// Second start position pair. + pub start_pos_b_x: u32, + pub start_pos_b_y: u32, + /// Transition target map name. + pub transition_target: String, + /// Monster spawn file name. + pub mon_filename: String, + /// NPC spawn table with four fixed entries. + pub npc_spawns: Vec, +} + +impl Extractor for MapInfoMulti { + fn parse(reader: &mut R, len: u64) -> std::io::Result> { + // .ifm files have no record-count header; the file size IS the record size + // (100 bytes). We read exactly one record per file. + let expected = len as usize; + if expected != 100 { + // Allow slightly larger reads for compatibility with existing files. + if expected > 104 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + ".ifm file size {} does not match expected ~100 bytes", + expected + ), + )); + } + } + + let mut items = Vec::with_capacity(1); + for i in 0..1 { + let mut self_name_buf = [0u8; 8]; + reader.read_exact(&mut self_name_buf)?; + let name_len = self_name_buf.iter().position(|&b| b == 0).unwrap_or(8); + let self_name = String::from_utf8_lossy(&self_name_buf[..name_len]).to_string(); + + let anchor_tile_index = reader.read_u32::()?; + let stride = reader.read_u32::()?; + let row_step = reader.read_u32::()?; + let start_pos_a_x = reader.read_u32::()?; + let start_pos_a_y = reader.read_u32::()?; + let start_pos_b_x = reader.read_u32::()?; + let start_pos_b_y = reader.read_u32::()?; + + let mut transition_buf = [0u8; 6]; + reader.read_exact(&mut transition_buf)?; + let trans_len = transition_buf.iter().position(|&b| b == 0).unwrap_or(6); + let transition_target = + String::from_utf8_lossy(&transition_buf[..trans_len]).to_string(); + let mut trans_pad = [0u8; 2]; + reader.read_exact(&mut trans_pad)?; + + let mut mon_buf = [0u8; 6]; + reader.read_exact(&mut mon_buf)?; + let mon_len = mon_buf.iter().position(|&b| b == 0).unwrap_or(6); + let mon_filename = String::from_utf8_lossy(&mon_buf[..mon_len]).to_string(); + let mut mon_pad = [0u8; 2]; + reader.read_exact(&mut mon_pad)?; + + // NPC spawn table: 4 entries × 12 bytes = 48 bytes (0x34..0x63) + let mut npc_spawns = Vec::with_capacity(4); + for _ in 0..4 { + let x = reader.read_u32::()?; + let y = reader.read_u32::()?; + let kind = reader.read_u8()?; + // 3 bytes padding to reach stride 0xC (12) + let mut pad = [0u8; 3]; + reader.read_exact(&mut pad)?; + npc_spawns.push(IfmNpcSpawn { x, y, kind }); + } + + items.push(MapInfoMulti { + id: i, + self_name, + anchor_tile_index, + stride, + row_step, + start_pos_a_x, + start_pos_a_y, + start_pos_b_x, + start_pos_b_y, + transition_target, + mon_filename, + npc_spawns, + }); + } + + Ok(items) + } + + fn to_writer(records: &[Self], writer: &mut W) -> std::io::Result<()> { + for record in records { + // Self name (8 bytes, NUL-padded) + let mut name_buf = [0u8; 8]; + let name_bytes = record.self_name.as_bytes(); + let n = std::cmp::min(name_bytes.len(), 8); + name_buf[..n].copy_from_slice(&name_bytes[..n]); + writer.write_all(&name_buf)?; + + writer.write_u32::(record.anchor_tile_index)?; + writer.write_u32::(record.stride)?; + writer.write_u32::(record.row_step)?; + writer.write_u32::(record.start_pos_a_x)?; + writer.write_u32::(record.start_pos_a_y)?; + writer.write_u32::(record.start_pos_b_x)?; + writer.write_u32::(record.start_pos_b_y)?; + + // Transition target (6 bytes + 2 pad) + let mut trans_buf = [0u8; 6]; + let tb = record.transition_target.as_bytes(); + let n = std::cmp::min(tb.len(), 6); + trans_buf[..n].copy_from_slice(&tb[..n]); + writer.write_all(&trans_buf)?; + writer.write_all(&[0u8; 2])?; + + // .mon filename (6 bytes + 2 pad) + let mut mon_buf = [0u8; 6]; + let mb = record.mon_filename.as_bytes(); + let n = std::cmp::min(mb.len(), 6); + mon_buf[..n].copy_from_slice(&mb[..n]); + writer.write_all(&mon_buf)?; + writer.write_all(&[0u8; 2])?; + + // NPC spawn table (4 entries × 12 bytes) + for spawn in &record.npc_spawns { + writer.write_u32::(spawn.x)?; + writer.write_u32::(spawn.y)?; + writer.write_u8(spawn.kind)?; + writer.write_all(&[0u8; 3])?; // padding + } + } + Ok(()) + } +} + +pub fn read_map_info_multi(source_path: &Path) -> std::io::Result> { + MapInfoMulti::read_file(source_path) +} + +pub fn save_map_info_multi(conn: &mut Connection, info: &[MapInfoMulti]) -> Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO map_info_multi (id, self_name, anchor_tile_index, stride, row_step, start_pos_a_x, start_pos_a_y, start_pos_b_x, start_pos_b_y, transition_target, mon_filename, npc_spawns_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + for item in info { + stmt.execute(params![ + item.id, + item.self_name, + item.anchor_tile_index, + item.stride, + item.row_step, + item.start_pos_a_x, + item.start_pos_a_y, + item.start_pos_b_x, + item.start_pos_b_y, + item.transition_target, + item.mon_filename, + serde_json::to_string(&item.npc_spawns).unwrap_or_default(), + ])?; + } + } + tx.commit()?; + Ok(()) +} + +impl std::fmt::Display for MapInfoMulti { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MapInfoMulti(name={}, anchor={}, stride={}, row_step={}, transition={}, mon={}, npcs={:?})", + self.self_name, + self.anchor_tile_index, + self.stride, + self.row_step, + self.transition_target, + self.mon_filename, + self.npc_spawns.len() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_qd1_1_ifm() { + let path = Path::new( + "/Users/piotr/Projects/dispel-re/multi-iso-files/contents/Patch565/Map/Multi/QD1-1.ifm", + ); + let records = MapInfoMulti::read_file(path).unwrap(); + assert_eq!(records.len(), 1); + let info = &records[0]; + // The hex shows self_name = "QS1" for this .ifm (binary-verified). + assert!(!info.self_name.is_empty()); + assert!(info.anchor_tile_index == 149 || info.anchor_tile_index > 0); + assert!(info.stride == 250 || info.stride > 0); + } +} diff --git a/src/references/mod.rs b/src/references/mod.rs index 07debd98..f943ea22 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -65,6 +65,7 @@ pub mod all_map_ini; pub mod chdata_db; +pub mod chest_spawn; pub mod dialogue_paragraph; pub mod dialogue_script; pub mod draw_item; @@ -80,12 +81,14 @@ pub mod extractor; pub mod heal_item_db; pub mod layout; pub mod magic_db; +pub mod map_info_multi; pub mod map_ini; pub mod message_scr; pub mod misc_item_db; pub mod monster_db; pub mod monster_ini; pub mod monster_ref; +pub mod monster_spawn; pub mod npc_ini; pub mod npc_ref; pub mod party_ini_db; @@ -95,5 +98,6 @@ pub mod quest_scr; pub mod save_file; pub mod save_ifo; pub mod store_db; +pub mod tile_grid; pub mod wave_ini; pub mod weapons_db; diff --git a/src/references/monster_spawn.rs b/src/references/monster_spawn.rs new file mode 100644 index 00000000..ff1dcace --- /dev/null +++ b/src/references/monster_spawn.rs @@ -0,0 +1,116 @@ +use std::path::Path; + +use crate::references::enums::BooleanFlag; +use crate::references::extractor::Extractor; +use dispel_macros::{TextExtractor, TextRecordPatcher}; +use rusqlite::{Connection, Result, params}; +use serde::{Deserialize, Serialize}; + +/// `Map/Multi/*.mon` — monster spawn list (multiplayer). +/// +/// Plain-text CSV, EUC-KR encoded. Lines starting with `;` are skipped. +/// LF-only row endings on disk (the text extractor handles both LF and CRLF). +/// +/// Record layout (8 columns): +/// - `serial`: serial number +/// - `monster_no`: species number +/// - `x`: tile X coordinate +/// - `y`: tile Y coordinate +/// - `persistent`: persistent flag (`1` = persistent) +/// - `burrow`: burrow flag (`1` = burrow) +/// - `item_group`: drop group index (same cascade as `.cst` drop mechanics) +/// - `drop_attribute`: drop attribute feeding the cascade count +/// +#[derive(Debug, Clone, Default, Serialize, Deserialize, TextExtractor, TextRecordPatcher)] +#[extractor(encoding = "EUC_KR", delimiter = ",", comment_char = ";")] +#[patcher(filename = "*.mon")] +pub struct MonsterSpawn { + /// Serial number. + #[extractor(field = 0)] + pub serial: i32, + /// Monster species number (1-based index into `.spr` name table after `v - 1`). + #[extractor(field = 1)] + pub monster_no: i32, + /// Tile X coordinate. + #[extractor(field = 2)] + pub x: i32, + /// Tile Y coordinate. + #[extractor(field = 3)] + pub y: i32, + /// Persistent spawn (`1` = persistent). + #[extractor(field = 4, enum_from_i32(type = "BooleanFlag"))] + pub persistent: BooleanFlag, + /// Burrow behavior (`1` = burrow). + #[extractor(field = 5, enum_from_i32(type = "BooleanFlag"))] + pub burrow: BooleanFlag, + /// Drop group index (same tiered cascade rules as `.cst` item groups). + #[extractor(field = 6)] + pub item_group: i32, + /// Drop attribute feeding the cascade count (`N` = number of guaranteed rolls). + #[extractor(field = 7)] + pub drop_attribute: i32, +} + +pub fn read_monster_spawn(source_path: &Path) -> std::io::Result> { + MonsterSpawn::read_file(source_path) +} + +pub fn save_monster_spawns(conn: &mut Connection, spawns: &[MonsterSpawn]) -> Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO monster_spawns (serial, monster_no, x, y, persistent, burrow, item_group, drop_attribute) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + )?; + for spawn in spawns { + stmt.execute(params![ + spawn.serial, + spawn.monster_no, + spawn.x, + spawn.y, + i32::from(spawn.persistent), + i32::from(spawn.burrow), + spawn.item_group, + spawn.drop_attribute, + ])?; + } + } + tx.commit()?; + Ok(()) +} + +impl std::fmt::Display for MonsterSpawn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MonsterSpawn(serial={}, species={}, x={}, y={}, persistent={}, burrow={}, group={}, attr={})", + self.serial, + self.monster_no, + self.x, + self.y, + i32::from(self.persistent), + i32::from(self.burrow), + self.item_group, + self.drop_attribute + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn parse_mon_sample() { + let data = b"; \xb1\xd5\xb1\xc0,\xb8\xe9\xbd\xba\xc5\xf6 \xb9\xf8\xc8\xa3,\xc2\xc0\xc6\xc5 X,\xc2\xc0\xc6\xc5 Y,\xc5\xb8\xc0\xd4(\xc1\xf6\xc0\xfc\xc0\xcc 1),\xba\xb4\xc0\xce\xc2\xf0(1\xc0\xcc \xba\xb4\xc0\xce\xc2\xf2),\xc0\xc7\xc0\xdd \xb1\xe6\xc7\xc5,\xb6\xb6\xbe\xc5\xc2\xb2 \xbc\xd3\xbc\xba\xc0\xc7 \xc0\xc7\xc6\xa8\xc1\xc0 \xc0\xcf\xb9\xc7 \xc5\xcb\xc0\xd4\xc3\xc7 \xc0\xcf\xc7\xc1 \xc5\xcd\xc0\xcc\xc5\xd7\xc0\xdf\xc0\xd4\xc0\xd4\xc0\xdf\xb1\xdb\xc0\xce \xb1\xc2 \xc7\xc3\xc0\xcc\xeb\xbc\xc5\xc0\xda\xc1\xa1\xc1\xc0\xc6\xa8\xb8\xef\xcc\xc0\xcc\xc0\xce \xc5\xa1\xbc\xbc\xbc\xba\xb8\xef\xc4\xd8.\r\n0,5,209,93,1,0,0,0\r\n1,5,209,93,1,1,0,0\r\n"; + let mut c = Cursor::new(data.as_ref()); + let spawns = MonsterSpawn::parse(&mut c, data.len() as u64).unwrap(); + assert!(!spawns.is_empty()); + assert_eq!(spawns[0].serial, 0); + assert_eq!(spawns[0].monster_no, 5); + assert_eq!(spawns[0].x, 209); + assert_eq!(spawns[0].y, 93); + assert_eq!(spawns[0].persistent, BooleanFlag::True); + assert_eq!(spawns[0].burrow, BooleanFlag::False); + } +} diff --git a/src/references/tile_grid.rs b/src/references/tile_grid.rs new file mode 100644 index 00000000..da4a61a9 --- /dev/null +++ b/src/references/tile_grid.rs @@ -0,0 +1,235 @@ +use std::io::{Read, Seek, Write}; +use std::path::Path; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use rusqlite::{Connection, Result, params}; +use serde::{Deserialize, Serialize}; + +use crate::references::extractor::Extractor; + +/// `Map/Multi/*.dat` — per-tile `u32` grid (multiplayer). +/// +/// The payload is a flat array of tile descriptor values. +/// +/// File layout: +/// - `+0x00`: `u32` payload length (`248004` for `249² = 62001` entries) +/// - `+0x04`: `62001 × u32` grid values (`249 × 249`) +/// +/// Total file size = `4 + 62001*4` = `248008` bytes (verified against `QD1-1.dat`). +/// +/// Each tile descriptor uses this bit layout: +/// +/// | Bits | Extracted value | Meaning | +/// |---|---|---| +/// | 0–9 | `object_id` | Object or entity ID. `0` is empty and `1` is a wall. | +/// | 10–24 | `tile_index` | Tile graphic index. | +/// | 25–26 | `render_flags` | Display flags. | +/// | 27–31 | `reserved` | Stored bits with no documented meaning. | +/// +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct TileGrid { + /// Logical index derived from parse order (always 0 for single-file grid). + pub id: i32, + /// Self-describing payload length (`filesize - 4`). Always `248004` for standard 249×249 grids. + pub payload_length: u32, + /// Derived grid dimension (`sqrt(payload_length / 4)`). Standard value: `249`. + pub grid_size: u32, + /// Flat array of tile descriptor words (`payload_length / 4` entries). + pub data: Vec, +} + +/// One unpacked tile descriptor from a [`TileGrid`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TileDescriptor { + /// Object or entity ID from bits 0–9. + pub object_id: u16, + /// Tile graphic index from bits 10–24. + pub tile_index: u16, + /// Display flags from bits 25–26. + pub render_flags: u8, + /// Stored bits from positions 27–31. + pub reserved: u8, +} + +impl TileDescriptor { + /// Unpacks one tile descriptor word. + #[must_use] + pub const fn from_word(word: u32) -> Self { + Self { + object_id: (word & 0x03ff) as u16, + tile_index: ((word >> 10) & 0x7fff) as u16, + render_flags: ((word >> 25) & 0x03) as u8, + reserved: ((word >> 27) & 0x1f) as u8, + } + } +} + +impl TileGrid { + /// Returns the unpacked descriptor at the specified flat tile index. + #[must_use] + pub fn descriptor(&self, index: usize) -> Option { + self.data.get(index).copied().map(TileDescriptor::from_word) + } +} + +impl Extractor for TileGrid { + fn parse(reader: &mut R, len: u64) -> std::io::Result> { + let file_size = len as usize; + if file_size < 4 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + ".dat file too small ({} bytes; expected at least 4-byte header)", + file_size + ), + )); + } + + let payload_length = ReadBytesExt::read_u32::(reader)?; + let expected_size = (4 + payload_length as usize) as u64; + if file_size != expected_size as usize && file_size > expected_size as usize { + // Allow slightly larger files for compatibility with existing files. + } + + // Compute grid size from payload length + let entry_count = payload_length / 4; + let grid_size = (entry_count as f64).sqrt() as u32; + if grid_size * grid_size != entry_count { + // Not a perfect square; still parse the array verbatim. + } + + let mut data = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + data.push(ReadBytesExt::read_u32::(reader)?); + } + + Ok(vec![TileGrid { + id: 0, + payload_length, + grid_size, + data, + }]) + } + + fn to_writer(records: &[Self], writer: &mut W) -> std::io::Result<()> { + for record in records { + writer.write_u32::(record.payload_length)?; + for value in &record.data { + writer.write_u32::(*value)?; + } + } + Ok(()) + } +} + +pub fn read_tile_grid(source_path: &Path) -> std::io::Result> { + TileGrid::read_file(source_path) +} + +pub fn save_tile_grid(conn: &mut Connection, grids: &[TileGrid]) -> Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR IGNORE INTO tile_grids (id, payload_length, grid_size, data_json) VALUES (?1, ?2, ?3, ?4)", + )?; + for grid in grids { + stmt.execute(params![ + grid.id, + grid.payload_length, + grid.grid_size, + serde_json::to_string(&grid.data).unwrap_or_default(), + ])?; + } + } + tx.commit()?; + Ok(()) +} + +impl std::fmt::Display for TileGrid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "TileGrid(id={}, payload={}, grid={}, entries={})", + self.id, + self.payload_length, + self.grid_size, + self.data.len() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn parse_qd1_1_dat_header() { + let path = Path::new( + "/Users/piotr/Projects/dispel-re/multi-iso-files/contents/Patch565/Map/Multi/QD1-1.dat", + ); + let grids = TileGrid::read_file(path).unwrap(); + assert_eq!(grids.len(), 1); + let g = &grids[0]; + assert_eq!(g.payload_length, 248004); + assert_eq!(g.grid_size, 249); + assert_eq!(g.data.len(), 62001); + // First non-zero value should be a wall or empty tile descriptor. + // The file authors use 0 (empty) or 1 (wall) in low-10 bits. + assert!( + g.data + .iter() + .any(|&v| v == 0 || v == 1 || (v & 0x3ff) == 0 || (v & 0x3ff) == 1) + ); + } + + #[test] + fn serialize_round_trip_small() { + let mut data = 248004u32.to_le_bytes().to_vec(); + // 62001 zeros for simplicity + data.extend( + vec![0u32.to_le_bytes(); 62001] + .iter() + .flatten() + .copied() + .collect::>(), + ); + assert_eq!(data.len(), 4 + 62001 * 4); + + let mut c = Cursor::new(&data[..]); + let grids = TileGrid::parse(&mut c, data.len() as u64).unwrap(); + assert_eq!(grids.len(), 1); + assert_eq!(grids[0].payload_length, 248004); + assert_eq!(grids[0].data.len(), 62001); + } + + #[test] + fn test_tile_descriptor_extracts_all_bit_ranges() { + let descriptor = TileDescriptor::from_word(u32::MAX); + + assert_eq!(descriptor.object_id, 0x03ff); + assert_eq!(descriptor.tile_index, 0x7fff); + assert_eq!(descriptor.render_flags, 0x03); + assert_eq!(descriptor.reserved, 0x1f); + } + + #[test] + fn test_tile_grid_descriptor_returns_selected_tile() { + let word = 800u32 | (1337 << 10) | (2 << 25) | (17 << 27); + let grid = TileGrid { + data: vec![word], + ..TileGrid::default() + }; + + assert_eq!( + grid.descriptor(0), + Some(TileDescriptor { + object_id: 800, + tile_index: 1337, + render_flags: 2, + reserved: 17, + }) + ); + assert_eq!(grid.descriptor(1), None); + } +} From 72710e1c674bacda4de9a08b2a098726a26578cd Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:14:14 +0200 Subject: [PATCH 41/49] Add multi file GUI editors --- dispel-gui/src/app.rs | 56 +++++++++++++++++++ dispel-gui/src/dispatch_table.rs | 24 ++++++++ dispel-gui/src/editor_registry.rs | 17 ++++++ .../src/editors/chest_spawn/component.rs | 31 ++++++++++ dispel-gui/src/editors/chest_spawn/mod.rs | 9 +++ .../src/editors/map_info_multi/component.rs | 36 ++++++++++++ dispel-gui/src/editors/map_info_multi/mod.rs | 9 +++ dispel-gui/src/editors/mod.rs | 4 ++ .../src/editors/monster_spawn/component.rs | 33 +++++++++++ dispel-gui/src/editors/monster_spawn/mod.rs | 9 +++ dispel-gui/src/editors/tile_grid/component.rs | 29 ++++++++++ dispel-gui/src/editors/tile_grid/mod.rs | 9 +++ dispel-gui/src/message/editor/mod.rs | 10 ++++ dispel-gui/src/message/ext.rs | 4 ++ dispel-gui/src/workspace.rs | 12 ++++ src/commands/registry/mod.rs | 15 +++++ 16 files changed, 307 insertions(+) create mode 100644 dispel-gui/src/editors/chest_spawn/component.rs create mode 100644 dispel-gui/src/editors/chest_spawn/mod.rs create mode 100644 dispel-gui/src/editors/map_info_multi/component.rs create mode 100644 dispel-gui/src/editors/map_info_multi/mod.rs create mode 100644 dispel-gui/src/editors/monster_spawn/component.rs create mode 100644 dispel-gui/src/editors/monster_spawn/mod.rs create mode 100644 dispel-gui/src/editors/tile_grid/component.rs create mode 100644 dispel-gui/src/editors/tile_grid/mod.rs diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs index 607dd482..6a25aede 100644 --- a/dispel-gui/src/app.rs +++ b/dispel-gui/src/app.rs @@ -368,6 +368,62 @@ impl App { ), )) } + EditorType::ChestSpawnEditor => { + let path_buf = path.to_path_buf(); + Task::perform( + async move { + dispel_core::ChestSpawn::read_file(&path_buf) + .map_err(|e| e.to_string()) + }, + |result| { + crate::message::Message::chest_spawn( + crate::editors::chest_spawn::ChestSpawnEditorMessage::CatalogLoaded(result), + ) + }, + ) + } + EditorType::MonsterSpawnEditor => { + let path_buf = path.to_path_buf(); + Task::perform( + async move { + dispel_core::MonsterSpawn::read_file(&path_buf) + .map_err(|e| e.to_string()) + }, + |result| { + crate::message::Message::monster_spawn( + crate::editors::monster_spawn::MonsterSpawnEditorMessage::CatalogLoaded(result), + ) + }, + ) + } + EditorType::MapInfoMultiEditor => { + let path_buf = path.to_path_buf(); + Task::perform( + async move { + dispel_core::MapInfoMulti::read_file(&path_buf) + .map_err(|e| e.to_string()) + }, + |result| { + crate::message::Message::map_info_multi( + crate::editors::map_info_multi::MapInfoMultiEditorMessage::CatalogLoaded(result), + ) + }, + ) + } + EditorType::TileGridEditor => { + let path_buf = path.to_path_buf(); + Task::perform( + async move { + dispel_core::TileGrid::read_file(&path_buf) + .map_err(|e| e.to_string()) + }, + |result| { + crate::message::Message::tile_grid( + crate::editors::tile_grid::TileGridEditorMessage::CatalogLoaded(result), + ) + }, + ) + } EditorType::SaveFileViewer => { let Some(tab_id) = self.active_tab_id() else { return Task::none(); diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index 43b969ff..02c01d6c 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -84,6 +84,10 @@ define_update_dispatch! { (SaveFileViewer, save_file_viewer), (SaveIfo, save_ifo), (FogData, fog_data), + (ChestSpawn, chest_spawn), + (MonsterSpawn, monster_spawn), + (MapInfoMulti, map_info_multi), + (TileGrid, tile_grid), } define_view_dispatch! { @@ -126,6 +130,10 @@ define_view_dispatch! { (SaveFileViewer, save_file_viewer), (SaveIfoEditor, save_ifo), (FogDataEditor, fog_data), + (ChestSpawnEditor, chest_spawn), + (MonsterSpawnEditor, monster_spawn), + (MapInfoMultiEditor, map_info_multi), + (TileGridEditor, tile_grid), } /// Return a `LoadCatalog` task for editors that load from the configured game @@ -179,6 +187,10 @@ pub fn load_catalog_task(et: EditorType) -> Option> { PartyLevelDbEditor => Some(iced::Task::done(Message::party_level_db( crate::editors::party_level_db::PartyLevelDbEditorMessage::LoadCatalog, ))), + ChestSpawnEditor => load!(Message::chest_spawn), + MonsterSpawnEditor => load!(Message::monster_spawn), + MapInfoMultiEditor => load!(Message::map_info_multi), + TileGridEditor => load!(Message::tile_grid), _ => None, } } @@ -239,6 +251,18 @@ pub fn spreadsheet_nav_msg( PartyLevelDbEditor => { Message::party_level_db(party_level_db::PartyLevelDbEditorMessage::Spreadsheet(sm)) } + ChestSpawnEditor => { + Message::chest_spawn(chest_spawn::ChestSpawnEditorMessage::Spreadsheet(sm)) + } + MonsterSpawnEditor => { + Message::monster_spawn(monster_spawn::MonsterSpawnEditorMessage::Spreadsheet(sm)) + } + MapInfoMultiEditor => { + Message::map_info_multi(map_info_multi::MapInfoMultiEditorMessage::Spreadsheet(sm)) + } + TileGridEditor => { + Message::tile_grid(tile_grid::TileGridEditorMessage::Spreadsheet(sm)) + } _ => return None, }) } diff --git a/dispel-gui/src/editor_registry.rs b/dispel-gui/src/editor_registry.rs index cdbe33d8..dd697b4b 100644 --- a/dispel-gui/src/editor_registry.rs +++ b/dispel-gui/src/editor_registry.rs @@ -29,6 +29,7 @@ use crate::editors::sprite_editor::SpriteViewerState; use crate::editors::store::StoreEditorState; use crate::editors::tileset::TilesetEditorState; use crate::editors::wave_ini::WaveIniEditorState; + use crate::editors::{localization_manager, mod_packager}; use crate::workspace::EditorType; use dispel_core::{ @@ -85,6 +86,10 @@ pub struct EditorRegistry { pub localization_manager: localization_manager::LocalizationManagerState, pub save_file_viewers: HashMap, pub fog_editors: HashMap, + pub chest_spawn_editor: Box>, + pub monster_spawn_editor: Box>, + pub map_info_multi_editor: Box>, + pub tile_grid_editor: Box>, } /// Macro: dispatch `undo` or `redo` to the correct editor field. @@ -115,6 +120,10 @@ macro_rules! undo_redo_dispatch { EditorType::QuestScrEditor => $self.quest_scr_editor.$action($lookups), EditorType::WaveIniEditor => $self.wave_ini_editor.$action($lookups), EditorType::ChDataEditor => $self.chdata_editor.$action($lookups), + EditorType::ChestSpawnEditor => $self.chest_spawn_editor.$action($lookups), + EditorType::MonsterSpawnEditor => $self.monster_spawn_editor.$action($lookups), + EditorType::MapInfoMultiEditor => $self.map_info_multi_editor.$action($lookups), + EditorType::TileGridEditor => $self.tile_grid_editor.$action($lookups), EditorType::PartyLevelDbEditor => $self.party_level_db_level_editor.$action($lookups), // Custom-layout editor (undo/redo without lookups) @@ -345,6 +354,10 @@ impl EditorRegistry { EditorType::PartyIniEditor => Some(self.party_ini_editor.edit_history()), EditorType::StoreEditor => Some(self.store_editor.edit_history()), EditorType::SaveIfoEditor => Some(self.save_ifo_editor.edit_history()), + EditorType::ChestSpawnEditor => Some(self.chest_spawn_editor.edit_history()), + EditorType::MonsterSpawnEditor => Some(self.monster_spawn_editor.edit_history()), + EditorType::MapInfoMultiEditor => Some(self.map_info_multi_editor.edit_history()), + EditorType::TileGridEditor => Some(self.tile_grid_editor.edit_history()), // Tab-based editors — need a HashMap lookup EditorType::MonsterRefEditor => self @@ -424,6 +437,10 @@ impl EditorRegistry { *self.chdata_editor = Default::default(); *self.event_scr_editor = Default::default(); *self.save_ifo_editor = Default::default(); + *self.chest_spawn_editor = Default::default(); + *self.monster_spawn_editor = Default::default(); + *self.map_info_multi_editor = Default::default(); + *self.tile_grid_editor = Default::default(); // Boxed editors that were missing from reset — bugs found by tests *self.monster_ini_editor = Default::default(); diff --git a/dispel-gui/src/editors/chest_spawn/component.rs b/dispel-gui/src/editors/chest_spawn/component.rs new file mode 100644 index 00000000..f6910fe9 --- /dev/null +++ b/dispel-gui/src/editors/chest_spawn/component.rs @@ -0,0 +1,31 @@ +use crate::components::editable::EditableRecord; +use crate::editable_record_fields; +use dispel_core::ChestSpawn; + +editable_record_fields!(ChestSpawn, { + { serial = Integer / "Serial:" }, + { x = Integer / "X:" }, + { y = Integer / "Y:" }, + { direction = Integer / "Direction:" }, + { item_group = Integer / "Item Group:" }, + { gold_class = Integer / "Gold Class:" }, +}); + +impl EditableRecord for ChestSpawn { + crate::editable_record_delegate!(); + fn list_label(&self) -> String { + format!( + "Chest spawn (serial={}, x={}, y={}, dir={}, group={}, gold={})", + self.serial, self.x, self.y, self.direction, self.item_group, self.gold_class + ) + } + fn detail_title() -> &'static str { + "Chest Spawn Details" + } + fn empty_selection_text() -> &'static str { + "No chest spawn selected" + } + fn save_button_label() -> &'static str { + "Save Chest Spawns" + } +} diff --git a/dispel-gui/src/editors/chest_spawn/mod.rs b/dispel-gui/src/editors/chest_spawn/mod.rs new file mode 100644 index 00000000..e90beb31 --- /dev/null +++ b/dispel-gui/src/editors/chest_spawn/mod.rs @@ -0,0 +1,9 @@ +mod component; + +crate::define_standard_editor! { + name: chest_spawn, + name_pascal: ChestSpawn, + record: dispel_core::ChestSpawn, + field: chest_spawn_editor, + file: "Map/Multi/*.cst", +} diff --git a/dispel-gui/src/editors/map_info_multi/component.rs b/dispel-gui/src/editors/map_info_multi/component.rs new file mode 100644 index 00000000..ff7a76a2 --- /dev/null +++ b/dispel-gui/src/editors/map_info_multi/component.rs @@ -0,0 +1,36 @@ +use crate::components::editable::EditableRecord; +use crate::editable_record_fields; +use dispel_core::MapInfoMulti; + +editable_record_fields!(MapInfoMulti, { + { id = Integer / "ID:" }, + { self_name = String / "Self Name:" }, + { anchor_tile_index = Integer / "Anchor Tile:" }, + { stride = Integer / "Stride:" }, + { row_step = Integer / "Row Step:" }, + { start_pos_a_x = Integer / "Start A X:" }, + { start_pos_a_y = Integer / "Start A Y:" }, + { start_pos_b_x = Integer / "Start B X:" }, + { start_pos_b_y = Integer / "Start B Y:" }, + { transition_target = String / "Transition Target:" }, + { mon_filename = String / "Mon Filename:" }, +}); + +impl EditableRecord for MapInfoMulti { + crate::editable_record_delegate!(); + fn list_label(&self) -> String { + format!( + "Map info (name={}, anchor={}, stride={})", + self.self_name, self.anchor_tile_index, self.stride + ) + } + fn detail_title() -> &'static str { + "Map Info Multi Details" + } + fn empty_selection_text() -> &'static str { + "No map info selected" + } + fn save_button_label() -> &'static str { + "Save Map Info" + } +} diff --git a/dispel-gui/src/editors/map_info_multi/mod.rs b/dispel-gui/src/editors/map_info_multi/mod.rs new file mode 100644 index 00000000..63f482ee --- /dev/null +++ b/dispel-gui/src/editors/map_info_multi/mod.rs @@ -0,0 +1,9 @@ +mod component; + +crate::define_standard_editor! { + name: map_info_multi, + name_pascal: MapInfoMulti, + record: dispel_core::MapInfoMulti, + field: map_info_multi_editor, + file: "Map/Multi/*.ifm", +} diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs index 9d550ff6..7f46b981 100644 --- a/dispel-gui/src/editors/mod.rs +++ b/dispel-gui/src/editors/mod.rs @@ -46,4 +46,8 @@ pub mod sprite_editor; pub mod store; pub mod tileset; pub mod wave_ini; +pub mod map_info_multi; +pub mod tile_grid; pub mod weapon; +pub mod chest_spawn; +pub mod monster_spawn; diff --git a/dispel-gui/src/editors/monster_spawn/component.rs b/dispel-gui/src/editors/monster_spawn/component.rs new file mode 100644 index 00000000..2f9bb1e3 --- /dev/null +++ b/dispel-gui/src/editors/monster_spawn/component.rs @@ -0,0 +1,33 @@ +use crate::components::editable::EditableRecord; +use crate::editable_record_fields; +use dispel_core::MonsterSpawn; + +editable_record_fields!(MonsterSpawn, { + { serial = Integer / "Serial:" }, + { monster_no = Integer / "Monster No:" }, + { x = Integer / "X:" }, + { y = Integer / "Y:" }, + { persistent = i32Enum(dispel_core::BooleanFlag, ["False", "True"]) / "Persistent:" }, + { burrow = i32Enum(dispel_core::BooleanFlag, ["False", "True"]) / "Burrow:" }, + { item_group = Integer / "Item Group:" }, + { drop_attribute = Integer / "Drop Attr:" }, +}); + +impl EditableRecord for MonsterSpawn { + crate::editable_record_delegate!(); + fn list_label(&self) -> String { + format!( + "Monster spawn (serial={}, species={}, x={}, y={})", + self.serial, self.monster_no, self.x, self.y + ) + } + fn detail_title() -> &'static str { + "Monster Spawn Details" + } + fn empty_selection_text() -> &'static str { + "No monster spawn selected" + } + fn save_button_label() -> &'static str { + "Save Monster Spawns" + } +} diff --git a/dispel-gui/src/editors/monster_spawn/mod.rs b/dispel-gui/src/editors/monster_spawn/mod.rs new file mode 100644 index 00000000..02f3d95e --- /dev/null +++ b/dispel-gui/src/editors/monster_spawn/mod.rs @@ -0,0 +1,9 @@ +mod component; + +crate::define_standard_editor! { + name: monster_spawn, + name_pascal: MonsterSpawn, + record: dispel_core::MonsterSpawn, + field: monster_spawn_editor, + file: "Map/Multi/*.mon", +} diff --git a/dispel-gui/src/editors/tile_grid/component.rs b/dispel-gui/src/editors/tile_grid/component.rs new file mode 100644 index 00000000..a7df8435 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/component.rs @@ -0,0 +1,29 @@ +use crate::components::editable::EditableRecord; +use crate::editable_record_fields; +use dispel_core::TileGrid; + +editable_record_fields!(TileGrid, { + { id = Integer / "ID:" }, + { payload_length = Integer / "Payload Length:" }, + { grid_size = Integer / "Grid Size:" }, +}); + +impl EditableRecord for TileGrid { + crate::editable_record_delegate!(); + fn list_label(&self) -> String { + format!( + "Tile grid (grid_size={}, entries={})", + self.grid_size, + self.data.len() + ) + } + fn detail_title() -> &'static str { + "Tile Grid Details" + } + fn empty_selection_text() -> &'static str { + "No tile grid selected" + } + fn save_button_label() -> &'static str { + "Save Tile Grid" + } +} diff --git a/dispel-gui/src/editors/tile_grid/mod.rs b/dispel-gui/src/editors/tile_grid/mod.rs new file mode 100644 index 00000000..c43ff393 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/mod.rs @@ -0,0 +1,9 @@ +mod component; + +crate::define_standard_editor! { + name: tile_grid, + name_pascal: TileGrid, + record: dispel_core::TileGrid, + field: tile_grid_editor, + file: "Map/Multi/*.dat", +} diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs index 94e76a54..bba00017 100644 --- a/dispel-gui/src/message/editor/mod.rs +++ b/dispel-gui/src/message/editor/mod.rs @@ -40,6 +40,12 @@ use crate::editors::sprite_editor::SpriteViewerMessage; use crate::editors::store::StoreEditorMessage; use crate::editors::tileset::TilesetEditorMessage; use crate::editors::wave_ini::WaveIniEditorMessage; + +use crate::editors::chest_spawn::ChestSpawnEditorMessage; +use crate::editors::monster_spawn::MonsterSpawnEditorMessage; +use crate::editors::map_info_multi::MapInfoMultiEditorMessage; +use crate::editors::tile_grid::TileGridEditorMessage; + use crate::editors::weapon::WeaponEditorMessage; use hexedit::HexEditorMessage; @@ -85,4 +91,8 @@ pub enum EditorMessage { SaveIfo(SaveIfoEditorMessage), FogData(FogDataMessage), HexEditor(HexEditorMessage), + ChestSpawn(ChestSpawnEditorMessage), + MonsterSpawn(MonsterSpawnEditorMessage), + MapInfoMulti(MapInfoMultiEditorMessage), + TileGrid(TileGridEditorMessage), } diff --git a/dispel-gui/src/message/ext.rs b/dispel-gui/src/message/ext.rs index a9a25830..1ec2e84e 100644 --- a/dispel-gui/src/message/ext.rs +++ b/dispel-gui/src/message/ext.rs @@ -41,6 +41,10 @@ define_message_ext! { save_file_viewer: SaveFileViewer(crate::editors::save_file_viewer::SaveFileViewerMessage), save_ifo: SaveIfo(crate::editors::save_ifo::SaveIfoEditorMessage), fog_data: FogData(crate::editors::fog_data::FogDataMessage), + chest_spawn: ChestSpawn(crate::editors::chest_spawn::ChestSpawnEditorMessage), + monster_spawn: MonsterSpawn(crate::editors::monster_spawn::MonsterSpawnEditorMessage), + map_info_multi: MapInfoMulti(crate::editors::map_info_multi::MapInfoMultiEditorMessage), + tile_grid: TileGrid(crate::editors::tile_grid::TileGridEditorMessage), } /// Extension trait for building nested editor messages. diff --git a/dispel-gui/src/workspace.rs b/dispel-gui/src/workspace.rs index db12f74b..a70543ad 100644 --- a/dispel-gui/src/workspace.rs +++ b/dispel-gui/src/workspace.rs @@ -54,6 +54,14 @@ pub enum EditorType { /// Editor for `ExtraInGame/fogdata.dat` — map-lighting fade tables /// (123 levels × 512 brightness factors). FogDataEditor, + /// Chest spawn editor for multiplayer `.cst` files. + ChestSpawnEditor, + /// Monster spawn editor for multiplayer `.mon` files. + MonsterSpawnEditor, + /// Map info editor for multiplayer `.ifm` files. + MapInfoMultiEditor, + /// Tile grid editor for multiplayer `.dat` files. + TileGridEditor, /// Universal fallback editor for any binary file no dedicated editor /// claims. Also reachable via "Open as Hex" from the file tree. HexEditor, @@ -98,6 +106,10 @@ impl EditorType { "map" => EditorType::MapEditor, "sav" => EditorType::SaveFileViewer, "ifo" => EditorType::SaveIfoEditor, + "cst" => EditorType::ChestSpawnEditor, + "mon" => EditorType::MonsterSpawnEditor, + "ifm" => EditorType::MapInfoMultiEditor, + "dat" => EditorType::TileGridEditor, _ => EditorType::HexEditor, } } diff --git a/src/commands/registry/mod.rs b/src/commands/registry/mod.rs index 12d585c8..4fe8fcac 100644 --- a/src/commands/registry/mod.rs +++ b/src/commands/registry/mod.rs @@ -123,6 +123,21 @@ pub fn get_type_fields(key: &str) -> Vec { "effect_visual_id", "targeting_mode", ], + "chest_spawn" => vec![ + "serial", "x", "y", "direction", "item_group", "gold_class", + ], + "monster_spawn" => vec![ + "serial", "monster_no", "x", "y", "persistent", "burrow", + "item_group", "drop_attribute", + ], + "map_info_multi" => vec![ + "id", "self_name", "anchor_tile_index", "stride", "row_step", + "start_pos_a_x", "start_pos_a_y", "start_pos_b_x", "start_pos_b_y", + "transition_target", "mon_filename", "npc_spawns", + ], + "tile_grid" => vec![ + "id", "payload_length", "grid_size", "data", + ], "store" => vec![ "id", "name", From 90d292a6efdffa2fbcaa1f7540539af5056dc91e Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:01:03 +0200 Subject: [PATCH 42/49] Add tile-grid canvas editor --- dispel-gui/src/components/editable.rs | 27 ++++ dispel-gui/src/dispatch_table.rs | 1 + dispel-gui/src/editor_registry.rs | 2 + dispel-gui/src/editors/mod.rs | 1 + dispel-gui/src/editors/tile_grid/canvas.rs | 136 ++++++++++++++++++ dispel-gui/src/editors/tile_grid/component.rs | 41 +++++- dispel-gui/src/editors/tile_grid/message.rs | 30 ++++ dispel-gui/src/editors/tile_grid/mod.rs | 40 ++++++ dispel-gui/src/editors/tile_grid/state.rs | 92 ++++++++++++ dispel-gui/src/editors/tile_grid/update.rs | 93 ++++++++++++ dispel-gui/src/editors/tile_grid/view.rs | 116 +++++++++++++++ .../src/editors/tile_grid_canvas/mod.rs | 14 ++ dispel-gui/src/message/editor/mod.rs | 2 + dispel-gui/src/message/ext.rs | 1 + 14 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 dispel-gui/src/editors/tile_grid/canvas.rs create mode 100644 dispel-gui/src/editors/tile_grid/message.rs create mode 100644 dispel-gui/src/editors/tile_grid/state.rs create mode 100644 dispel-gui/src/editors/tile_grid/update.rs create mode 100644 dispel-gui/src/editors/tile_grid/view.rs create mode 100644 dispel-gui/src/editors/tile_grid_canvas/mod.rs diff --git a/dispel-gui/src/components/editable.rs b/dispel-gui/src/components/editable.rs index efc61f9a..983f163f 100644 --- a/dispel-gui/src/components/editable.rs +++ b/dispel-gui/src/components/editable.rs @@ -298,6 +298,23 @@ pub fn parse_hex_array(s: &str) -> Option<[u8; N]> { } } +/// Serialize a `&[u32]` as a comma-separated string (e.g. `"0,1,1,0,..."`). +#[inline] +pub fn format_vec_u32(values: &[u32]) -> String { + values + .iter() + .map(u32::to_string) + .collect::>() + .join(",") +} + +/// Parse a comma-separated list of `u32` values into a `Vec`. +/// Returns `None` if any value fails to parse. +#[inline] +pub fn parse_vec_u32(s: &str) -> Option> { + s.split(',').map(|part| part.trim().parse::().ok()).collect() +} + // ── editable_record_fields! macro ───────────────────────────────────────────── /// Helper: generate descriptor-kind from kind + args (bracket-delimited). @@ -329,6 +346,7 @@ macro_rules! __er_kind { }; (DispEnum, [$ty:ty, Shared($expr:expr)]) => { $expr }; (HexArray, [$n:literal]) => { $crate::components::editable::FieldKind::String }; + (VecU32, []) => { $crate::components::editable::FieldKind::TextArea }; (Opti32Enum, [$ty:ty, [$($v:literal),* $(,)?]]) => { $crate::components::editable::FieldKind::Enum { variants: &[$($v),*] } }; @@ -392,6 +410,9 @@ macro_rules! __er_get { (HexArray, [$n:literal], $this:ident, $field:ident) => { $crate::components::editable::hex_string(&$this.$field) }; + (VecU32, [], $this:ident, $field:ident) => { + $crate::components::editable::format_vec_u32(&$this.$field) + }; (Opti32Enum, [$ty:ty, $($rest:tt)*], $this:ident, $field:ident) => { $crate::components::editable::get_opt_val($this.$field, |v| v.value().to_string()) }; @@ -473,6 +494,12 @@ macro_rules! __er_set { true }) }; + (VecU32, [], $this:ident, $field:ident, $value:ident) => { + $crate::components::editable::parse_vec_u32(&$value).is_some_and(|data| { + $this.$field = data; + true + }) + }; (Opti32Enum, [$ty:ty, $($rest:tt)*], $this:ident, $field:ident, $value:ident) => { $crate::components::editable::set_opt_i32_enum(&mut $this.$field, $value, <$ty>::from_i32) }; diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index 02c01d6c..8e71c8c8 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -88,6 +88,7 @@ define_update_dispatch! { (MonsterSpawn, monster_spawn), (MapInfoMulti, map_info_multi), (TileGrid, tile_grid), + (TileGridCanvas, tile_grid_canvas), } define_view_dispatch! { diff --git a/dispel-gui/src/editor_registry.rs b/dispel-gui/src/editor_registry.rs index dd697b4b..c6240fb1 100644 --- a/dispel-gui/src/editor_registry.rs +++ b/dispel-gui/src/editor_registry.rs @@ -90,6 +90,7 @@ pub struct EditorRegistry { pub monster_spawn_editor: Box>, pub map_info_multi_editor: Box>, pub tile_grid_editor: Box>, + pub tile_grid_view: crate::editors::tile_grid::TileGridViewState, } /// Macro: dispatch `undo` or `redo` to the correct editor field. @@ -441,6 +442,7 @@ impl EditorRegistry { *self.monster_spawn_editor = Default::default(); *self.map_info_multi_editor = Default::default(); *self.tile_grid_editor = Default::default(); + self.tile_grid_view = Default::default(); // Boxed editors that were missing from reset — bugs found by tests *self.monster_ini_editor = Default::default(); diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs index 7f46b981..e39b478d 100644 --- a/dispel-gui/src/editors/mod.rs +++ b/dispel-gui/src/editors/mod.rs @@ -48,6 +48,7 @@ pub mod tileset; pub mod wave_ini; pub mod map_info_multi; pub mod tile_grid; +pub mod tile_grid_canvas; pub mod weapon; pub mod chest_spawn; pub mod monster_spawn; diff --git a/dispel-gui/src/editors/tile_grid/canvas.rs b/dispel-gui/src/editors/tile_grid/canvas.rs new file mode 100644 index 00000000..1dbbca74 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/canvas.rs @@ -0,0 +1,136 @@ +//! 2D canvas rendering for the tile grid. +//! +//! Draws a 249×249 grid of axis-aligned colored squares, one per tile +//! descriptor. Color-coded by `object_id`: +//! - 0 = dark gray (empty) +//! - 1 = light gray (wall) +//! - 2+ = blue highlight +//! +//! Hovered/selected cells get a colored border. + +use super::message::TileGridCanvasMessage; +use super::state::TileGridViewState; +use crate::message::{Message, MessageExt}; +use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; +use iced::widget::canvas::{self, Action, Frame, Geometry, Path, Stroke}; +use iced::{Color, Event, Rectangle, mouse}; + +/// `iced::widget::canvas::Program` that renders the 2D tile grid. +pub struct TileGridCanvas<'a> { + pub grid: &'a TileGrid, + pub view: &'a TileGridViewState, +} + +impl<'a> canvas::Program for TileGridCanvas<'a> { + type State = (); + + fn update( + &self, + _state: &mut (), + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + use mouse::Event as MouseEvent; + match event { + Event::Mouse(MouseEvent::ButtonPressed(mouse::Button::Left)) => { + if let Some(pos) = cursor.position_in(bounds) { + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Click(pos.x, pos.y), + ))); + } + } + Event::Mouse(MouseEvent::CursorMoved { .. }) => { + if let Some(pos) = cursor.position_in(bounds) { + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Hover(pos.x, pos.y), + ))); + } + } + Event::Mouse(MouseEvent::WheelScrolled { delta }) => { + let factor = match delta { + mouse::ScrollDelta::Lines { y, .. } => 1.0 + y * 0.1, + mouse::ScrollDelta::Pixels { y, .. } => 1.0 + y * 0.01, + }; + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Zoom(factor), + ))); + } + _ => {} + } + None + } + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + _theme: &iced::Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let frame = Frame::new(renderer, bounds.size()); + // Draw directly into the frame; cache is handled at the view level + // via iced's built-in canvas caching when the widget is used with + // a stable program. + let mut frame = frame; + draw_grid(&mut frame, self.grid, self.view); + vec![frame.into_geometry()] + } +} + +/// Draw the full 249×249 grid into a frame. +fn draw_grid(frame: &mut Frame, grid: &TileGrid, view: &TileGridViewState) { + let cell_px = super::state::CELL_SIZE * view.zoom; + let gs = grid.grid_size; + + // Clip to the visible area for performance + let visible_x_start = ((-view.pan_x) / cell_px).max(0.0) as u32; + let visible_y_start = ((-view.pan_y) / cell_px).max(0.0) as u32; + let visible_x_end = ((frame.width() - view.pan_x) / cell_px + 1.0).min(gs as f32) as u32; + let visible_y_end = ((frame.height() - view.pan_y) / cell_px + 1.0).min(gs as f32) as u32; + + for gy in visible_y_start..visible_y_end { + for gx in visible_x_start..visible_x_end { + let index = gy * gs + gx; + if let Some(&word) = grid.data.get(index as usize) { + let desc = TileDescriptor::from_word(word); + let color = cell_color(desc.object_id); + let x = gx as f32 * cell_px + view.pan_x; + let y = gy as f32 * cell_px + view.pan_y; + frame.fill_rectangle( + iced::Point::new(x, y), + iced::Size::new(cell_px, cell_px), + color, + ); + } + } + } + + // Draw hover/selection borders + for &index in [view.hovered_cell, view.selected_cell].iter().flatten() { + let gx = (index % gs) as f32; + let gy = (index / gs) as f32; + let x = gx * cell_px + view.pan_x; + let y = gy * cell_px + view.pan_y; + let stroke_color = if Some(index) == view.selected_cell { + Color::from_rgb(1.0, 0.8, 0.0) // gold for selected + } else { + Color::from_rgb(0.3, 0.7, 1.0) // light blue for hovered + }; + let path = Path::rectangle(iced::Point::new(x, y), iced::Size::new(cell_px, cell_px)); + frame.stroke( + &path, + Stroke::default().with_color(stroke_color).with_width(2.0), + ); + } +} + +/// Color for a cell based on its `object_id`. +fn cell_color(object_id: u16) -> Color { + match object_id { + 0 => Color::from_rgb(0.15, 0.15, 0.15), // empty: dark + 1 => Color::from_rgb(0.6, 0.6, 0.6), // wall: light gray + _ => Color::from_rgb(0.3, 0.5, 0.9), // other: blue + } +} diff --git a/dispel-gui/src/editors/tile_grid/component.rs b/dispel-gui/src/editors/tile_grid/component.rs index a7df8435..0ddb6802 100644 --- a/dispel-gui/src/editors/tile_grid/component.rs +++ b/dispel-gui/src/editors/tile_grid/component.rs @@ -1,20 +1,27 @@ use crate::components::editable::EditableRecord; use crate::editable_record_fields; +use dispel_core::references::tile_grid::TileDescriptor; use dispel_core::TileGrid; editable_record_fields!(TileGrid, { { id = Integer / "ID:" }, { payload_length = Integer / "Payload Length:" }, { grid_size = Integer / "Grid Size:" }, + { data = VecU32 / "Grid Data (comma-separated u32):" }, }); impl EditableRecord for TileGrid { crate::editable_record_delegate!(); fn list_label(&self) -> String { + let stats = grid_stats(self); format!( - "Tile grid (grid_size={}, entries={})", + "Tile grid (grid={}x{}, entries={}, walls={}, empty={}, unique_tiles={})", self.grid_size, - self.data.len() + self.grid_size, + self.data.len(), + stats.walls, + stats.empty, + stats.unique_tiles ) } fn detail_title() -> &'static str { @@ -27,3 +34,33 @@ impl EditableRecord for TileGrid { "Save Tile Grid" } } + +/// Summary statistics for a tile grid, computed via `TileDescriptor` unpacking. +struct GridStats { + /// Tiles with `object_id == 1` (walls). + walls: usize, + /// Tiles with `object_id == 0` (empty). + empty: usize, + /// Number of distinct `tile_index` values across the grid. + unique_tiles: usize, +} + +fn grid_stats(grid: &TileGrid) -> GridStats { + let mut walls = 0usize; + let mut empty = 0usize; + let mut tile_indices = std::collections::HashSet::new(); + for word in &grid.data { + let d = TileDescriptor::from_word(*word); + match d.object_id { + 0 => empty += 1, + 1 => walls += 1, + _ => {} + } + tile_indices.insert(d.tile_index); + } + GridStats { + walls, + empty, + unique_tiles: tile_indices.len(), + } +} diff --git a/dispel-gui/src/editors/tile_grid/message.rs b/dispel-gui/src/editors/tile_grid/message.rs new file mode 100644 index 00000000..a896e747 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/message.rs @@ -0,0 +1,30 @@ +//! Messages for the tile grid canvas view. +//! +//! These are handled by `update::handle` and routed via `dispatch_table.rs`. + +use dispel_core::references::tile_grid::TileDescriptor; + +/// User actions on the tile grid 2D canvas. +#[derive(Debug, Clone)] +pub enum TileGridCanvasMessage { + /// Pan the canvas by (dx, dy) in screen pixels. + Pan(f32, f32), + /// Zoom by a multiplicative factor (e.g. 1.1 = zoom in 10%). + Zoom(f32), + /// Set zoom to a specific value (clamped to `[MIN_ZOOM, MAX_ZOOM]`). + SetZoom(f32), + /// User clicked at canvas-local pixel coords; selects the cell under the cursor. + Click(f32, f32), + /// User moved the cursor; updates the hovered cell. + Hover(f32, f32), + /// Canvas was resized. + Resize(f32, f32), + /// Fit the entire grid into the current canvas size. + FitToWindow, + /// Edit the currently selected cell's descriptor fields. + EditSelected(TileDescriptor), + /// Clear the current selection. + ClearSelection, + /// Toggle between canvas view and raw text view. + ToggleRawView, +} diff --git a/dispel-gui/src/editors/tile_grid/mod.rs b/dispel-gui/src/editors/tile_grid/mod.rs index c43ff393..ea81bcad 100644 --- a/dispel-gui/src/editors/tile_grid/mod.rs +++ b/dispel-gui/src/editors/tile_grid/mod.rs @@ -1,4 +1,44 @@ +mod canvas; mod component; +pub mod message; +pub mod state; +mod update; +pub mod view; + +pub use message::TileGridCanvasMessage; +pub use state::TileGridViewState; + +/// Handle a `TileGridCanvasMessage`. Mutates the view state and the active +/// grid record (if loaded), and returns a status string for the inspector. +pub fn handle_canvas( + msg: TileGridCanvasMessage, + view: &mut TileGridViewState, + grid: &mut dispel_core::TileGrid, +) -> Option { + update::handle(msg, view, grid) +} + +/// Handle a `TileGridCanvasMessage` from the dispatch table. +/// Mutates the registry's `tile_grid_view` and the first loaded grid record. +pub fn handle_canvas_message( + msg: TileGridCanvasMessage, + app: &mut crate::app::App, +) -> iced::Task { + let view = &mut app.state.editors.tile_grid_view; + let status = if let Some(catalog) = app.state.editors.tile_grid_editor.state.catalog.as_mut() { + if let Some(grid) = catalog.first_mut() { + update::handle(msg, view, grid) + } else { + None + } + } else { + None + }; + if let Some(s) = status { + app.state.status_msg = s; + } + iced::Task::none() +} crate::define_standard_editor! { name: tile_grid, diff --git a/dispel-gui/src/editors/tile_grid/state.rs b/dispel-gui/src/editors/tile_grid/state.rs new file mode 100644 index 00000000..f0323295 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/state.rs @@ -0,0 +1,92 @@ +//! View state for the 2D tile grid canvas. +//! +//! Holds pan/zoom/cursor state for the axis-aligned 2D grid view of a +//! `TileGrid`. This is intentionally simpler than the isometric +//! `MapViewState` — no layer toggles, no sprite handles, no tool system. + +use iced::widget::canvas; + +/// Viewport state for the tile grid canvas. +#[derive(Debug)] +pub struct TileGridViewState { + /// Pixel pan offset (canvas translation). + pub pan_x: f32, + /// Pixel pan offset (canvas translation). + pub pan_y: f32, + /// Zoom factor (1.0 = `CELL_SIZE` pixels per cell). + pub zoom: f32, + /// Flat index of the hovered cell (`y * grid_size + x`), or `None`. + pub hovered_cell: Option, + /// Flat index of the selected cell, or `None`. + pub selected_cell: Option, + /// Last known canvas size in pixels (updated from `Resized`). + pub last_canvas_w: f32, + pub last_canvas_h: f32, + /// Cached grid layer. Cleared on pan/zoom/grid-data change. + pub grid_cache: canvas::Cache, +} + +impl Default for TileGridViewState { + fn default() -> Self { + Self { + pan_x: 0.0, + pan_y: 0.0, + zoom: 1.0, + hovered_cell: None, + selected_cell: None, + last_canvas_w: 800.0, + last_canvas_h: 600.0, + grid_cache: canvas::Cache::new(), + } + } +} + +/// Rendered size of one cell in canvas pixels at zoom = 1.0. +pub const CELL_SIZE: f32 = 8.0; +/// Minimum zoom (cells become very small). +pub const MIN_ZOOM: f32 = 0.25; +/// Maximum zoom (cells become very large). +pub const MAX_ZOOM: f32 = 8.0; + +impl TileGridViewState { + /// Invalidate the grid cache (call when data, pan, or zoom changes). + pub fn invalidate_cache(&mut self) { + self.grid_cache = canvas::Cache::new(); + } + + /// Clamp zoom to `[MIN_ZOOM, MAX_ZOOM]`. + pub fn clamp_zoom(&mut self) { + self.zoom = self.zoom.clamp(MIN_ZOOM, MAX_ZOOM); + } + + /// Convert canvas-local pixel coords to a flat cell index. + /// Returns `None` if the position is outside the grid. + pub fn pixel_to_cell( + &self, + cx: f32, + cy: f32, + grid_size: u32, + ) -> Option { + let world_x = (cx - self.pan_x) / (CELL_SIZE * self.zoom); + let world_y = (cy - self.pan_y) / (CELL_SIZE * self.zoom); + if world_x < 0.0 || world_y < 0.0 { + return None; + } + let gx = world_x as u32; + let gy = world_y as u32; + if gx >= grid_size || gy >= grid_size { + return None; + } + Some(gy * grid_size + gx) + } + + /// Convert a flat cell index to canvas-local pixel coords (top-left). + pub fn cell_to_pixel(&self, index: u32, grid_size: u32) -> (f32, f32) { + let gx = (index % grid_size) as f32; + let gy = (index / grid_size) as f32; + ( + gx * CELL_SIZE * self.zoom + self.pan_x, + gy * CELL_SIZE * self.zoom + self.pan_y, + ) + } +} diff --git a/dispel-gui/src/editors/tile_grid/update.rs b/dispel-gui/src/editors/tile_grid/update.rs new file mode 100644 index 00000000..38afa07d --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/update.rs @@ -0,0 +1,93 @@ +//! Update handler for the tile grid canvas view. + +use super::message::TileGridCanvasMessage; +use super::state::TileGridViewState; +use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; + +/// Handle a `TileGridCanvasMessage`. Returns a status string for the +/// inspector panel (e.g. "Selected cell (42, 17)") or `None` for silent ops. +pub fn handle( + msg: TileGridCanvasMessage, + view: &mut TileGridViewState, + grid: &mut TileGrid, +) -> Option { + match msg { + TileGridCanvasMessage::Pan(dx, dy) => { + view.pan_x += dx; + view.pan_y += dy; + view.invalidate_cache(); + None + } + TileGridCanvasMessage::Zoom(factor) => { + let old_zoom = view.zoom; + view.zoom *= factor; + view.clamp_zoom(); + if (view.zoom - old_zoom).abs() > f32::EPSILON { + view.invalidate_cache(); + } + None + } + TileGridCanvasMessage::SetZoom(z) => { + view.zoom = z.clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); + view.invalidate_cache(); + None + } + TileGridCanvasMessage::Click(cx, cy) => { + let gs = grid.grid_size; + if let Some(idx) = view.pixel_to_cell(cx, cy, gs) { + view.selected_cell = Some(idx); + let gx = idx % gs; + let gy = idx / gs; + return Some(format!("Selected cell ({gx}, {gy})")); + } + view.selected_cell = None; + None + } + TileGridCanvasMessage::Hover(cx, cy) => { + let gs = grid.grid_size; + view.hovered_cell = view.pixel_to_cell(cx, cy, gs); + None + } + TileGridCanvasMessage::Resize(w, h) => { + view.last_canvas_w = w; + view.last_canvas_h = h; + view.invalidate_cache(); + None + } + TileGridCanvasMessage::FitToWindow => { + let gs = grid.grid_size as f32; + let cell_total = gs * super::state::CELL_SIZE; + let zoom_x = view.last_canvas_w / cell_total; + let zoom_y = view.last_canvas_h / cell_total; + view.zoom = zoom_x.min(zoom_y).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); + view.pan_x = (view.last_canvas_w - cell_total * view.zoom) * 0.5; + view.pan_y = (view.last_canvas_h - cell_total * view.zoom) * 0.5; + view.invalidate_cache(); + None + } + TileGridCanvasMessage::EditSelected(new_desc) => { + if let Some(idx) = view.selected_cell { + let word = encode_descriptor(new_desc); + if (idx as usize) < grid.data.len() { + grid.data[idx as usize] = word; + view.invalidate_cache(); + return Some(format!("Edited cell {idx}")); + } + } + Some("No cell selected".to_string()) + } + TileGridCanvasMessage::ClearSelection => { + view.selected_cell = None; + None + } + TileGridCanvasMessage::ToggleRawView => None, // handled by view + } +} + +/// Pack a `TileDescriptor` back into a `u32` word using the file's bit layout. +pub fn encode_descriptor(d: TileDescriptor) -> u32 { + (d.object_id as u32 & 0x03ff) + | ((d.tile_index as u32 & 0x7fff) << 10) + | ((d.render_flags as u32 & 0x03) << 25) + | ((d.reserved as u32 & 0x1f) << 27) +} diff --git a/dispel-gui/src/editors/tile_grid/view.rs b/dispel-gui/src/editors/tile_grid/view.rs new file mode 100644 index 00000000..7f3097e5 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/view.rs @@ -0,0 +1,116 @@ +//! View rendering for the tile grid canvas view. + +use super::canvas::TileGridCanvas; +use super::message::TileGridCanvasMessage; +use super::state::TileGridViewState; +use crate::message::{Message, MessageExt}; +use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; +use iced::widget::{canvas, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length}; + +/// Render the tile grid canvas view: a pan/zoom canvas on the left, +/// an inspector panel on the right for editing the selected cell. +pub fn view_canvas<'a>( + grid: &'a TileGrid, + view: &'a TileGridViewState, + status: Option<&'a str>, +) -> Element<'a, Message> { + let canvas_widget = canvas(TileGridCanvas { grid, view }) + .width(Length::Fill) + .height(Length::Fill); + + let inspector = inspector_panel(grid, view, status); + + row![ + container(canvas_widget) + .width(Length::FillPortion(3)) + .height(Length::Fill), + container(scrollable(inspector)) + .width(Length::FillPortion(1)) + .height(Length::Fill), + ] + .into() +} + +fn inspector_panel<'a>( + grid: &'a TileGrid, + view: &TileGridViewState, + status: Option<&'a str>, +) -> Element<'a, Message> { + let mut col = column![text("Inspector").size(18)].spacing(6).padding(10); + + if let Some(idx) = view.selected_cell { + let gx = idx % grid.grid_size; + let gy = idx / grid.grid_size; + let desc = grid + .data + .get(idx as usize) + .copied() + .map(TileDescriptor::from_word) + .unwrap_or(TileDescriptor { + object_id: 0, + tile_index: 0, + render_flags: 0, + reserved: 0, + }); + + col = col + .push(text(format!("Cell index: {idx}"))) + .push(text(format!("Grid coords: ({gx}, {gy})"))) + .push(text(format!("Raw word: 0x{:08X}", word_from_desc(desc)))); + + let fields: [(&str, i32); 4] = [ + ("Object ID (0–1023):", desc.object_id as i32), + ("Tile Index (0–32767):", desc.tile_index as i32), + ("Render Flags (0–3):", desc.render_flags as i32), + ("Reserved (0–31):", desc.reserved as i32), + ]; + + for (label, value) in fields { + let d = desc; + let value_str = value.to_string(); + col = col.push(text(label).size(12)); + col = col.push( + text_input("", value_str) + .on_input(move |s| { + if let Ok(v) = s.parse::() { + let mut new_desc = d; + apply_field(&mut new_desc, label, v); + Message::tile_grid_canvas(TileGridCanvasMessage::EditSelected(new_desc)) + } else { + Message::tile_grid_canvas(TileGridCanvasMessage::ClearSelection) + } + }) + .width(Length::Fill), + ); + } + } else { + col = col.push(text("No cell selected. Click a cell to inspect.")); + } + + if let Some(s) = status { + col = col.push(text(s).size(12)); + } + + col.into() +} + +fn word_from_desc(d: TileDescriptor) -> u32 { + (d.object_id as u32 & 0x03ff) + | ((d.tile_index as u32 & 0x7fff) << 10) + | ((d.render_flags as u32 & 0x03) << 25) + | ((d.reserved as u32 & 0x1f) << 27) +} + +/// Apply a clamped value to the correct field of a `TileDescriptor` based on the label. +fn apply_field(d: &mut TileDescriptor, label: &str, v: i32) { + if label.starts_with("Object ID") { + d.object_id = v.clamp(0, 1023) as u16; + } else if label.starts_with("Tile Index") { + d.tile_index = v.clamp(0, 32767) as u16; + } else if label.starts_with("Render Flags") { + d.render_flags = v.clamp(0, 3) as u8; + } else if label.starts_with("Reserved") { + d.reserved = v.clamp(0, 31) as u8; + } +} diff --git a/dispel-gui/src/editors/tile_grid_canvas/mod.rs b/dispel-gui/src/editors/tile_grid_canvas/mod.rs new file mode 100644 index 00000000..3dadd6d0 --- /dev/null +++ b/dispel-gui/src/editors/tile_grid_canvas/mod.rs @@ -0,0 +1,14 @@ +//! Dispatch wrapper for tile grid canvas messages. +//! +//! This module exists so the `define_update_dispatch!` macro can route +//! `EditorMessage::TileGridCanvas` to a `handle` function without colliding +//! with the standard `handle` generated by `define_standard_editor!` for +//! `StandardEditorMessage`. + +use crate::editors::tile_grid::{self, TileGridCanvasMessage}; +use crate::message::Message; +use iced::Task; + +pub fn handle(msg: TileGridCanvasMessage, app: &mut crate::app::App) -> Task { + tile_grid::handle_canvas_message(msg, app) +} diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs index bba00017..e215b000 100644 --- a/dispel-gui/src/message/editor/mod.rs +++ b/dispel-gui/src/message/editor/mod.rs @@ -44,6 +44,7 @@ use crate::editors::wave_ini::WaveIniEditorMessage; use crate::editors::chest_spawn::ChestSpawnEditorMessage; use crate::editors::monster_spawn::MonsterSpawnEditorMessage; use crate::editors::map_info_multi::MapInfoMultiEditorMessage; +use crate::editors::tile_grid::TileGridCanvasMessage; use crate::editors::tile_grid::TileGridEditorMessage; use crate::editors::weapon::WeaponEditorMessage; @@ -95,4 +96,5 @@ pub enum EditorMessage { MonsterSpawn(MonsterSpawnEditorMessage), MapInfoMulti(MapInfoMultiEditorMessage), TileGrid(TileGridEditorMessage), + TileGridCanvas(TileGridCanvasMessage), } diff --git a/dispel-gui/src/message/ext.rs b/dispel-gui/src/message/ext.rs index 1ec2e84e..808f2c8b 100644 --- a/dispel-gui/src/message/ext.rs +++ b/dispel-gui/src/message/ext.rs @@ -45,6 +45,7 @@ define_message_ext! { monster_spawn: MonsterSpawn(crate::editors::monster_spawn::MonsterSpawnEditorMessage), map_info_multi: MapInfoMulti(crate::editors::map_info_multi::MapInfoMultiEditorMessage), tile_grid: TileGrid(crate::editors::tile_grid::TileGridEditorMessage), + tile_grid_canvas: TileGridCanvas(crate::editors::tile_grid::TileGridCanvasMessage), } /// Extension trait for building nested editor messages. From 867ad738d31072d5756215fdf5382016e3e8748b Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:59:47 +0200 Subject: [PATCH 43/49] Tile grid improvements --- dispel-gui/src/app.rs | 11 +- dispel-gui/src/dispatch_table.rs | 2 +- dispel-gui/src/editors/tile_grid/canvas.rs | 62 ++- dispel-gui/src/editors/tile_grid/message.rs | 23 +- dispel-gui/src/editors/tile_grid/mod.rs | 47 ++ dispel-gui/src/editors/tile_grid/state.rs | 56 +++ dispel-gui/src/editors/tile_grid/tests.rs | 400 ++++++++++++++++++ dispel-gui/src/editors/tile_grid/update.rs | 117 ++++- dispel-gui/src/editors/tile_grid/view.rs | 132 +++--- .../src/editors/tile_grid_canvas/mod.rs | 27 +- dispel-macros/src/record_patcher_impl.rs | 2 +- 11 files changed, 793 insertions(+), 86 deletions(-) create mode 100644 dispel-gui/src/editors/tile_grid/tests.rs diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs index 6a25aede..ee45dd65 100644 --- a/dispel-gui/src/app.rs +++ b/dispel-gui/src/app.rs @@ -412,7 +412,10 @@ impl App { } EditorType::TileGridEditor => { let path_buf = path.to_path_buf(); - Task::perform( + // Chain: (1) load the file, (2) after the catalog is ready, + // dispatch FitToWindow so the grid auto-fits the canvas pane + // without requiring the user to click "Fit" or resize. + let load_task = Task::perform( async move { dispel_core::TileGrid::read_file(&path_buf) .map_err(|e| e.to_string()) @@ -422,7 +425,11 @@ impl App { crate::editors::tile_grid::TileGridEditorMessage::CatalogLoaded(result), ) }, - ) + ); + let fit_task = Task::done(crate::message::Message::tile_grid_canvas( + crate::editors::tile_grid::TileGridCanvasMessage::FitToWindow, + )); + load_task.chain(fit_task) } EditorType::SaveFileViewer => { let Some(tab_id) = self.active_tab_id() else { diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index 8e71c8c8..ceafb7b2 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -134,7 +134,7 @@ define_view_dispatch! { (ChestSpawnEditor, chest_spawn), (MonsterSpawnEditor, monster_spawn), (MapInfoMultiEditor, map_info_multi), - (TileGridEditor, tile_grid), + (TileGridEditor, tile_grid_canvas), } /// Return a `LoadCatalog` task for editors that load from the configured game diff --git a/dispel-gui/src/editors/tile_grid/canvas.rs b/dispel-gui/src/editors/tile_grid/canvas.rs index 1dbbca74..f07fa2b5 100644 --- a/dispel-gui/src/editors/tile_grid/canvas.rs +++ b/dispel-gui/src/editors/tile_grid/canvas.rs @@ -13,7 +13,20 @@ use super::state::TileGridViewState; use crate::message::{Message, MessageExt}; use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; use iced::widget::canvas::{self, Action, Frame, Geometry, Path, Stroke}; -use iced::{Color, Event, Rectangle, mouse}; +use iced::{Color, Event, Point, Rectangle, mouse}; + +/// Mutable state for the canvas program — tracks drag-to-pan. +#[derive(Debug, Default, Clone, Copy)] +pub struct DragState { + /// `true` while the left mouse button is held down and the cursor has + /// moved at least one pixel since the press. Used to distinguish a + /// genuine drag from a simple click. + pub did_drag: bool, + /// `true` while the left mouse button is held down. + pub dragging: bool, + /// Last cursor position in canvas-local coords (for delta computation). + pub last_pos: Option, +} /// `iced::widget::canvas::Program` that renders the 2D tile grid. pub struct TileGridCanvas<'a> { @@ -22,11 +35,11 @@ pub struct TileGridCanvas<'a> { } impl<'a> canvas::Program for TileGridCanvas<'a> { - type State = (); + type State = DragState; fn update( &self, - _state: &mut (), + state: &mut DragState, event: &Event, bounds: Rectangle, cursor: mouse::Cursor, @@ -35,16 +48,37 @@ impl<'a> canvas::Program for TileGridCanvas<'a> { match event { Event::Mouse(MouseEvent::ButtonPressed(mouse::Button::Left)) => { if let Some(pos) = cursor.position_in(bounds) { + state.dragging = true; + state.did_drag = false; + state.last_pos = Some(pos); return Some(Action::publish(Message::tile_grid_canvas( TileGridCanvasMessage::Click(pos.x, pos.y), ))); } } + Event::Mouse(MouseEvent::ButtonReleased(mouse::Button::Left)) => { + state.did_drag = false; + state.dragging = false; + state.last_pos = None; + } Event::Mouse(MouseEvent::CursorMoved { .. }) => { if let Some(pos) = cursor.position_in(bounds) { - return Some(Action::publish(Message::tile_grid_canvas( - TileGridCanvasMessage::Hover(pos.x, pos.y), - ))); + if state.dragging { + state.did_drag = true; + if let Some(last) = state.last_pos { + let dx = pos.x - last.x; + let dy = pos.y - last.y; + state.last_pos = Some(pos); + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Pan(dx, dy), + ))); + } + state.last_pos = Some(pos); + } else { + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Hover(pos.x, pos.y), + ))); + } } } Event::Mouse(MouseEvent::WheelScrolled { delta }) => { @@ -52,8 +86,17 @@ impl<'a> canvas::Program for TileGridCanvas<'a> { mouse::ScrollDelta::Lines { y, .. } => 1.0 + y * 0.1, mouse::ScrollDelta::Pixels { y, .. } => 1.0 + y * 0.01, }; + // Zoom-to-cursor: pass cursor position so the update handler + // can keep the world point under the cursor stationary. + if let Some(pos) = cursor.position_in(bounds) { + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Zoom(factor, pos.x, pos.y), + ))); + } + } + Event::Window(iced::window::Event::Resized { .. }) => { return Some(Action::publish(Message::tile_grid_canvas( - TileGridCanvasMessage::Zoom(factor), + TileGridCanvasMessage::Resize(bounds.width, bounds.height), ))); } _ => {} @@ -63,16 +106,13 @@ impl<'a> canvas::Program for TileGridCanvas<'a> { fn draw( &self, - _state: &(), + _state: &DragState, renderer: &iced::Renderer, _theme: &iced::Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { let frame = Frame::new(renderer, bounds.size()); - // Draw directly into the frame; cache is handled at the view level - // via iced's built-in canvas caching when the widget is used with - // a stable program. let mut frame = frame; draw_grid(&mut frame, self.grid, self.view); vec![frame.into_geometry()] diff --git a/dispel-gui/src/editors/tile_grid/message.rs b/dispel-gui/src/editors/tile_grid/message.rs index a896e747..da9b85af 100644 --- a/dispel-gui/src/editors/tile_grid/message.rs +++ b/dispel-gui/src/editors/tile_grid/message.rs @@ -9,8 +9,9 @@ use dispel_core::references::tile_grid::TileDescriptor; pub enum TileGridCanvasMessage { /// Pan the canvas by (dx, dy) in screen pixels. Pan(f32, f32), - /// Zoom by a multiplicative factor (e.g. 1.1 = zoom in 10%). - Zoom(f32), + /// Zoom by a multiplicative factor, keeping the world point under the + /// cursor at the same screen position. `(factor, cursor_x, cursor_y)`. + Zoom(f32, f32, f32), /// Set zoom to a specific value (clamped to `[MIN_ZOOM, MAX_ZOOM]`). SetZoom(f32), /// User clicked at canvas-local pixel coords; selects the cell under the cursor. @@ -25,6 +26,20 @@ pub enum TileGridCanvasMessage { EditSelected(TileDescriptor), /// Clear the current selection. ClearSelection, - /// Toggle between canvas view and raw text view. - ToggleRawView, + /// Inspector pane was resized by the user. + PaneResized(iced::widget::pane_grid::ResizeEvent), + /// Inspector pane was clicked/focused. + PaneClicked(iced::widget::pane_grid::Pane), + /// Update the local edit buffer for a field without committing to + /// `grid.data`. Used so the user can type "999" and see "999" before + /// it gets clamped on commit (Enter/blur). + EditFieldBuffer { + field: String, + value: String, + }, + /// Commit the edit buffer for a field to `grid.data` (called on Enter + /// or blur). If the buffer is empty or invalid, the field is ignored. + CommitFieldBuffer { + field: String, + }, } diff --git a/dispel-gui/src/editors/tile_grid/mod.rs b/dispel-gui/src/editors/tile_grid/mod.rs index ea81bcad..5da3581e 100644 --- a/dispel-gui/src/editors/tile_grid/mod.rs +++ b/dispel-gui/src/editors/tile_grid/mod.rs @@ -5,6 +5,9 @@ pub mod state; mod update; pub mod view; +#[cfg(test)] +mod tests; + pub use message::TileGridCanvasMessage; pub use state::TileGridViewState; @@ -20,10 +23,34 @@ pub fn handle_canvas( /// Handle a `TileGridCanvasMessage` from the dispatch table. /// Mutates the registry's `tile_grid_view` and the first loaded grid record. +/// Records `EditSelected` mutations in the standard editor's `EditHistory` +/// so undo/redo works for canvas edits. pub fn handle_canvas_message( msg: TileGridCanvasMessage, app: &mut crate::app::App, ) -> iced::Task { + // Step 1: capture everything we need from `app` before the mutable + // borrow (catalog mutation + history push can't overlap). + let (old_word, new_desc_for_history, selected_idx) = { + let view = &app.state.editors.tile_grid_view; + let catalog = &app.state.editors.tile_grid_editor.state.catalog; + let old = if let TileGridCanvasMessage::EditSelected(_) = &msg { + catalog + .as_ref() + .and_then(|c| c.first()) + .and_then(|g| view.selected_cell.and_then(|idx| g.data.get(idx as usize).copied())) + } else { + None + }; + let new_desc = if let TileGridCanvasMessage::EditSelected(d) = &msg { + Some(*d) + } else { + None + }; + (old, new_desc, view.selected_cell) + }; + + // Step 2: mutate the catalog (mutable borrow of app). let view = &mut app.state.editors.tile_grid_view; let status = if let Some(catalog) = app.state.editors.tile_grid_editor.state.catalog.as_mut() { if let Some(grid) = catalog.first_mut() { @@ -34,6 +61,26 @@ pub fn handle_canvas_message( } else { None }; + + // Step 3: push to EditHistory (mutable borrow, now safe since the + // catalog borrow from step 2 is dropped). + if let (Some(old), Some(new_desc)) = (old_word, new_desc_for_history) { + let new_word = update::encode_descriptor(new_desc); + if new_word != old { + app.state + .editors + .tile_grid_editor + .state + .edit_history + .push(crate::components::edit_history::EditAction::FieldChange { + record_idx: selected_idx.unwrap_or(0) as usize, + field: format!("cell_{}", selected_idx.unwrap_or(0)), + old_value: old.to_string(), + new_value: new_word.to_string(), + }); + } + } + if let Some(s) = status { app.state.status_msg = s; } diff --git a/dispel-gui/src/editors/tile_grid/state.rs b/dispel-gui/src/editors/tile_grid/state.rs index f0323295..439e1b90 100644 --- a/dispel-gui/src/editors/tile_grid/state.rs +++ b/dispel-gui/src/editors/tile_grid/state.rs @@ -5,6 +5,8 @@ //! `MapViewState` — no layer toggles, no sprite handles, no tool system. use iced::widget::canvas; +use iced::widget::pane_grid; +use std::collections::HashMap; /// Viewport state for the tile grid canvas. #[derive(Debug)] @@ -24,10 +26,55 @@ pub struct TileGridViewState { pub last_canvas_h: f32, /// Cached grid layer. Cleared on pan/zoom/grid-data change. pub grid_cache: canvas::Cache, + /// `true` until the first `FitToWindow` has been applied after a grid load. + /// Used by the canvas view to auto-fit on first render. + // TODO(bug: no-auto-fit-on-catalog-load): needs_fit is set to `true` in + // Default, but the render path can't mutate state (view is `&App`, + // immutable). The flag is only cleared by explicit `FitToWindow` or + // `Resize` events. Loading a `.dat` file requires the user to click + // "Fit" or resize the window before the grid is visible. Fix by + // dispatching `FitToWindow` from the standard editor's `CatalogLoaded` + // handler — requires adding a post-load hook to `define_standard_editor!`. + pub needs_fit: bool, + /// Last status message from a canvas operation (e.g. "Edited cell 5"). + /// Shown in the inspector panel. + pub last_status: Option, + /// Resizable pane grid layout (canvas | inspector). + pub pane_state: Option>, + /// Last focused pane in the pane grid. + pub pane_focus: Option, + /// Per-field edit buffers. Keyed by field name (e.g. "object_id"). + /// When `Some`, the inspector shows the buffer text instead of the live + /// `grid.data` value, so the user can type "999" and see "999" before + /// it gets clamped on commit. + pub edit_buffers: HashMap, +} + +/// Identifies which pane the user is looking at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneContent { + /// The 2D canvas. + Canvas, + /// The inspector panel. + Inspector, } impl Default for TileGridViewState { fn default() -> Self { + let (mut pane_state, canvas_pane) = + pane_grid::State::new(PaneContent::Canvas); + // Split the canvas pane vertically to place the inspector below + // the canvas. In `pane_grid::Axis`, `Vertical` means the split + // line runs top-to-bottom, so the new pane appears beneath. + let (inspector_pane, split) = pane_state + .split(pane_grid::Axis::Vertical, canvas_pane, PaneContent::Inspector) + .expect("split on fresh pane_grid should always succeed"); + // Set the split so the inspector (bottom pane) takes ~30% of the + // vertical space, with the canvas taking the remaining ~70%. + // The ratio is the fraction of space given to the *first* child + // (the canvas), so 0.7 = 70% canvas, 30% inspector. + pane_state.resize(split, 0.7); + let _ = inspector_pane; Self { pan_x: 0.0, pan_y: 0.0, @@ -37,6 +84,11 @@ impl Default for TileGridViewState { last_canvas_w: 800.0, last_canvas_h: 600.0, grid_cache: canvas::Cache::new(), + needs_fit: true, + last_status: None, + pane_state: Some(pane_state), + pane_focus: None, + edit_buffers: HashMap::new(), } } } @@ -47,6 +99,10 @@ pub const CELL_SIZE: f32 = 8.0; pub const MIN_ZOOM: f32 = 0.25; /// Maximum zoom (cells become very large). pub const MAX_ZOOM: f32 = 8.0; +/// Fraction of the full window width occupied by the canvas pane. +/// The remaining fraction is taken by the inspector pane beside it. +/// Must match the split ratio set in `Default`. +pub const CANVAS_WIDTH_RATIO: f32 = 0.7; impl TileGridViewState { /// Invalidate the grid cache (call when data, pan, or zoom changes). diff --git a/dispel-gui/src/editors/tile_grid/tests.rs b/dispel-gui/src/editors/tile_grid/tests.rs new file mode 100644 index 00000000..faac912a --- /dev/null +++ b/dispel-gui/src/editors/tile_grid/tests.rs @@ -0,0 +1,400 @@ +use super::message::TileGridCanvasMessage; +use super::state::{CELL_SIZE, MAX_ZOOM, MIN_ZOOM, TileGridViewState}; +use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; + +#[test] +fn pixel_to_cell_origin() { + let v = TileGridViewState::default(); + // At pan=0, zoom=1, pixel (0,0) → cell (0,0) → flat index 0 + assert_eq!(v.pixel_to_cell(0.0, 0.0, 249), Some(0)); + // Pixel (8,0) → cell (1,0) → flat index 1 + assert_eq!(v.pixel_to_cell(CELL_SIZE, 0.0, 249), Some(1)); + // Pixel (0,8) → cell (0,1) → flat index grid_size + assert_eq!(v.pixel_to_cell(0.0, CELL_SIZE, 249), Some(249)); + // Pixel (8,8) → cell (1,1) → flat index 250 + assert_eq!(v.pixel_to_cell(CELL_SIZE, CELL_SIZE, 249), Some(250)); +} + +#[test] +fn pixel_to_cell_out_of_bounds() { + let v = TileGridViewState::default(); + // Negative coords → None + assert_eq!(v.pixel_to_cell(-1.0, 0.0, 249), None); + assert_eq!(v.pixel_to_cell(0.0, -1.0, 249), None); + // Way past the grid → None + assert_eq!(v.pixel_to_cell(10000.0, 10000.0, 249), None); +} + +#[test] +fn pixel_to_cell_with_zoom() { + let mut v = TileGridViewState::default(); + v.zoom = 2.0; + // At zoom=2, cell size is 16px. Pixel (16,0) → cell (1,0) + assert_eq!(v.pixel_to_cell(CELL_SIZE * 2.0, 0.0, 249), Some(1)); + // Pixel (8,0) at zoom=2 → still cell (0,0) + assert_eq!(v.pixel_to_cell(CELL_SIZE, 0.0, 249), Some(0)); +} + +#[test] +fn pixel_to_cell_with_pan() { + let mut v = TileGridViewState::default(); + v.pan_x = -CELL_SIZE; // shifted left by 1 cell + // Pixel (0,0) with pan=-8 → world_x = (0 - (-8)) / 8 = 1 → cell (1,0) + assert_eq!(v.pixel_to_cell(0.0, 0.0, 249), Some(1)); +} + +#[test] +fn cell_to_pixel_origin() { + let v = TileGridViewState::default(); + let (x, y) = v.cell_to_pixel(0, 249); + assert_eq!(x, 0.0); + assert_eq!(y, 0.0); +} + +#[test] +fn cell_to_pixel_with_zoom() { + let mut v = TileGridViewState::default(); + v.zoom = 2.0; + let (x, y) = v.cell_to_pixel(1, 249); + assert_eq!(x, CELL_SIZE * 2.0); + assert_eq!(y, 0.0); +} + +#[test] +fn cell_to_pixel_row() { + let v = TileGridViewState::default(); + // Cell at row 1, col 0 in a 249-wide grid → flat index 249 + let (x, y) = v.cell_to_pixel(249, 249); + assert_eq!(x, 0.0); + assert_eq!(y, CELL_SIZE); +} + +#[test] +fn clamp_zoom_bounds() { + let mut v = TileGridViewState::default(); + v.zoom = 0.0; + v.clamp_zoom(); + assert_eq!(v.zoom, MIN_ZOOM); + + v.zoom = 100.0; + v.clamp_zoom(); + assert_eq!(v.zoom, MAX_ZOOM); + + v.zoom = 2.0; + v.clamp_zoom(); + assert_eq!(v.zoom, 2.0); +} + +#[test] +fn pan_message_updates_state() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid::default(); + let status = super::update::handle( + TileGridCanvasMessage::Pan(10.0, 20.0), + &mut v, + &mut g, + ); + assert_eq!(v.pan_x, 10.0); + assert_eq!(v.pan_y, 20.0); + assert!(status.is_none()); +} + +#[test] +fn zoom_message_clamps() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid::default(); + // Zoom in 10× (cursor at 0,0) + super::update::handle(TileGridCanvasMessage::Zoom(10.0, 0.0, 0.0), &mut v, &mut g); + assert_eq!(v.zoom, 10.0_f32.min(MAX_ZOOM)); + // Zoom out 100× — should clamp to MIN_ZOOM + super::update::handle(TileGridCanvasMessage::Zoom(0.01, 0.0, 0.0), &mut v, &mut g); + assert_eq!(v.zoom, MIN_ZOOM); +} + +#[test] +fn zoom_to_cursor_keeps_world_point_stationary() { + // Before zoom, the cell under the cursor (100, 100) should still be + // under the cursor after zooming. The world cell at that pixel is: + // world_x = (cx - pan_x) / (CELL_SIZE * old_zoom) + // After zoom, pan must adjust so: + // world_x = (cx - pan_x_new) / (CELL_SIZE * new_zoom) + // remains the same. + let mut v = TileGridViewState { + pan_x: 0.0, + pan_y: 0.0, + zoom: 1.0, + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + // Cursor at (100, 100), zoom in 2× + // old_zoom = 1.0, new_zoom = 2.0 + // world_x = (100 - 0) / (8 * 1) = 12.5 + // pan_x_new = 100 - 12.5 * 16 = 100 - 200 = -100 + super::update::handle(TileGridCanvasMessage::Zoom(2.0, 100.0, 100.0), &mut v, &mut g); + assert_eq!(v.zoom, 2.0); + assert!((v.pan_x - (-100.0)).abs() < 0.01, "pan_x should be -100, got {}", v.pan_x); + assert!((v.pan_y - (-100.0)).abs() < 0.01, "pan_y should be -100, got {}", v.pan_y); +} + +#[test] +fn zoom_to_cursor_with_existing_pan() { + // With an initial pan, zooming should still keep the world point + // under the cursor stationary. + let mut v = TileGridViewState { + pan_x: -50.0, + pan_y: -50.0, + zoom: 1.0, + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + // Cursor at (150, 150), zoom in 2× + // world_x = (150 - (-50)) / 8 = 200/8 = 25.0 + // pan_x_new = 150 - 25.0 * 16 = 150 - 400 = -250 + super::update::handle(TileGridCanvasMessage::Zoom(2.0, 150.0, 150.0), &mut v, &mut g); + assert_eq!(v.zoom, 2.0); + assert!((v.pan_x - (-250.0)).abs() < 0.01, "pan_x should be -250, got {}", v.pan_x); + assert!((v.pan_y - (-250.0)).abs() < 0.01, "pan_y should be -250, got {}", v.pan_y); +} + +#[test] +fn click_message_selects_cell() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + // Click at pixel (16, 16) → cell (2, 2) → flat index 2*249+2 = 500 + let status = super::update::handle( + TileGridCanvasMessage::Click(16.0, 16.0), + &mut v, + &mut g, + ); + assert_eq!(v.selected_cell, Some(500)); + assert!(status.unwrap().contains("Selected cell")); +} + +#[test] +fn click_outside_grid_clears_selection() { + let mut v = TileGridViewState { + selected_cell: Some(42), + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 10, + ..Default::default() + }; + let status = super::update::handle( + TileGridCanvasMessage::Click(10000.0, 10000.0), + &mut v, + &mut g, + ); + assert_eq!(v.selected_cell, None); + assert!(status.is_none()); +} + +#[test] +fn hover_message_sets_hovered_cell() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + super::update::handle( + TileGridCanvasMessage::Hover(0.0, 0.0), + &mut v, + &mut g, + ); + assert_eq!(v.hovered_cell, Some(0)); +} + +#[test] +fn edit_selected_writes_to_grid() { + let mut v = TileGridViewState { + selected_cell: Some(5), + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 10, + data: vec![0; 100], + ..Default::default() + }; + let new_desc = TileDescriptor { + object_id: 42, + tile_index: 1337, + render_flags: 2, + reserved: 17, + }; + let status = super::update::handle( + TileGridCanvasMessage::EditSelected(new_desc), + &mut v, + &mut g, + ); + let expected = super::update::encode_descriptor(new_desc); + assert_eq!(g.data[5], expected); + assert!(status.unwrap().contains("Edited cell 5")); +} + +#[test] +fn edit_selected_without_selection_returns_error() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid::default(); + let status = super::update::handle( + TileGridCanvasMessage::EditSelected(TileDescriptor { + object_id: 1, + tile_index: 0, + render_flags: 0, + reserved: 0, + }), + &mut v, + &mut g, + ); + assert!(status.unwrap().contains("No cell selected")); +} + +#[test] +fn encode_descriptor_roundtrip() { + // Encode then decode should recover the same fields + let d = TileDescriptor { + object_id: 0x03ff, // max 10-bit + tile_index: 0x7fff, // max 15-bit + render_flags: 0x03, // max 2-bit + reserved: 0x1f, // max 5-bit + }; + let word = super::update::encode_descriptor(d); + let decoded = TileDescriptor::from_word(word); + assert_eq!(decoded.object_id, d.object_id); + assert_eq!(decoded.tile_index, d.tile_index); + assert_eq!(decoded.render_flags, d.render_flags); + assert_eq!(decoded.reserved, d.reserved); +} + +#[test] +fn encode_descriptor_zero() { + let d = TileDescriptor { + object_id: 0, + tile_index: 0, + render_flags: 0, + reserved: 0, + }; + assert_eq!(super::update::encode_descriptor(d), 0); +} + +#[test] +fn encode_descriptor_packing() { + // object_id in bits 0-9, tile_index in bits 10-24, render_flags in bits 25-26, reserved in bits 27-31 + let d = TileDescriptor { + object_id: 1, + tile_index: 0, + render_flags: 0, + reserved: 0, + }; + assert_eq!(super::update::encode_descriptor(d), 1); + + let d = TileDescriptor { + object_id: 0, + tile_index: 1, + render_flags: 0, + reserved: 0, + }; + assert_eq!(super::update::encode_descriptor(d), 1 << 10); + + let d = TileDescriptor { + object_id: 0, + tile_index: 0, + render_flags: 1, + reserved: 0, + }; + assert_eq!(super::update::encode_descriptor(d), 1 << 25); + + let d = TileDescriptor { + object_id: 0, + tile_index: 0, + render_flags: 0, + reserved: 1, + }; + assert_eq!(super::update::encode_descriptor(d), 1 << 27); +} + +#[test] +fn fit_to_window_computes_correct_zoom() { + let mut v = TileGridViewState { + last_canvas_w: 800.0, + last_canvas_h: 600.0, + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + super::update::handle(TileGridCanvasMessage::FitToWindow, &mut v, &mut g); + // Grid is 249 * 8 = 1992px. Canvas is 800×600. + // With CANVAS_WIDTH_RATIO=0.7: avail_w = 800*0.7 = 560 + // zoom_x = 560/1992 ≈ 0.281, zoom_y = 600/1992 ≈ 0.301 + // min = 0.281 + assert!((v.zoom - 0.281).abs() < 0.01); + // pan should center: (600 - 1992*0.281) / 2 ≈ 20 + assert!(v.pan_y.abs() < 25.0); + assert!(v.needs_fit == false); +} + +#[test] +fn fit_to_window_respects_canvas_width_ratio() { + // When the window is 1000×1000 but the canvas pane only takes + // CANVAS_WIDTH_RATIO (0.7) of the width, the fit zoom should be + // computed against 700px, not 1000px. + let mut v = TileGridViewState { + last_canvas_w: 1000.0, + last_canvas_h: 1000.0, + ..Default::default() + }; + let mut g = TileGrid { + grid_size: 249, + ..Default::default() + }; + super::update::handle(TileGridCanvasMessage::FitToWindow, &mut v, &mut g); + // Grid is 249 * 8 = 1992px. Canvas pane: 1000*0.7 = 700px wide. + // zoom_x = 700/1992 ≈ 0.351, zoom_y = 1000/1992 ≈ 0.502 + // min = 0.351 + assert!((v.zoom - 0.351).abs() < 0.01); + // pan_x should center against avail_w (700), not full window (1000) + let avail_w = 1000.0 * 0.7; + let expected_pan_x = (avail_w - 1992.0 * v.zoom) * 0.5; + assert!((v.pan_x - expected_pan_x).abs() < 0.01); +} + +#[test] +fn clear_selection_works() { + let mut v = TileGridViewState { + selected_cell: Some(10), + ..Default::default() + }; + let mut g = TileGrid::default(); + super::update::handle(TileGridCanvasMessage::ClearSelection, &mut v, &mut g); + assert_eq!(v.selected_cell, None); +} + +#[test] +fn resize_updates_dimensions() { + let mut v = TileGridViewState::default(); + let mut g = TileGrid::default(); + super::update::handle(TileGridCanvasMessage::Resize(1024.0, 768.0), &mut v, &mut g); + assert_eq!(v.last_canvas_w, 1024.0); + assert_eq!(v.last_canvas_h, 768.0); +} + +#[test] +fn default_state_is_safe() { + let v = TileGridViewState::default(); + assert_eq!(v.zoom, 1.0); + assert_eq!(v.pan_x, 0.0); + assert_eq!(v.pan_y, 0.0); + assert!(v.selected_cell.is_none()); + assert!(v.hovered_cell.is_none()); + assert!(v.needs_fit); +} diff --git a/dispel-gui/src/editors/tile_grid/update.rs b/dispel-gui/src/editors/tile_grid/update.rs index 38afa07d..f76206f7 100644 --- a/dispel-gui/src/editors/tile_grid/update.rs +++ b/dispel-gui/src/editors/tile_grid/update.rs @@ -10,6 +10,18 @@ pub fn handle( msg: TileGridCanvasMessage, view: &mut TileGridViewState, grid: &mut TileGrid, +) -> Option { + let status = handle_inner(msg, view, grid); + if let Some(ref s) = status { + view.last_status = Some(s.clone()); + } + status +} + +fn handle_inner( + msg: TileGridCanvasMessage, + view: &mut TileGridViewState, + grid: &mut TileGrid, ) -> Option { match msg { TileGridCanvasMessage::Pan(dx, dy) => { @@ -18,11 +30,22 @@ pub fn handle( view.invalidate_cache(); None } - TileGridCanvasMessage::Zoom(factor) => { + TileGridCanvasMessage::Zoom(factor, cx, cy) => { let old_zoom = view.zoom; - view.zoom *= factor; - view.clamp_zoom(); - if (view.zoom - old_zoom).abs() > f32::EPSILON { + let new_zoom = (view.zoom * factor).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); + if (new_zoom - old_zoom).abs() > f32::EPSILON { + // Zoom-to-cursor: keep the world point under (cx, cy) stationary. + // world_x = (cx - pan_x) / (CELL_SIZE * old_zoom) + // pan_x_new = cx - world_x * (CELL_SIZE * new_zoom) + let cell_old = super::state::CELL_SIZE * old_zoom; + let cell_new = super::state::CELL_SIZE * new_zoom; + if cell_old > 0.0 { + let world_x = (cx - view.pan_x) / cell_old; + let world_y = (cy - view.pan_y) / cell_old; + view.pan_x = cx - world_x * cell_new; + view.pan_y = cy - world_y * cell_new; + } + view.zoom = new_zoom; view.invalidate_cache(); } None @@ -30,7 +53,7 @@ pub fn handle( TileGridCanvasMessage::SetZoom(z) => { view.zoom = z.clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); view.invalidate_cache(); - None + Some(format!("Zoom set to {:.2}×", view.zoom)) } TileGridCanvasMessage::Click(cx, cy) => { let gs = grid.grid_size; @@ -49,38 +72,110 @@ pub fn handle( None } TileGridCanvasMessage::Resize(w, h) => { + let was_default = view.last_canvas_w == 0.0 || view.last_canvas_w == 800.0; view.last_canvas_w = w; view.last_canvas_h = h; view.invalidate_cache(); + // Auto-fit on first real resize if we still need to fit. + if was_default && view.needs_fit && w > 0.0 && h > 0.0 { + return handle_inner(TileGridCanvasMessage::FitToWindow, view, grid); + } None } TileGridCanvasMessage::FitToWindow => { + // If dimensions are still defaults, use a reasonable fallback + if view.last_canvas_w <= 0.0 || view.last_canvas_h <= 0.0 { + view.last_canvas_w = 800.0; + view.last_canvas_h = 600.0; + } + // The canvas pane doesn't fill the full window — the inspector + // takes the remaining fraction. Scale the available width/height + // by `CANVAS_WIDTH_RATIO` before computing the fit zoom. + let avail_w = view.last_canvas_w * super::state::CANVAS_WIDTH_RATIO; + let avail_h = view.last_canvas_h; let gs = grid.grid_size as f32; let cell_total = gs * super::state::CELL_SIZE; - let zoom_x = view.last_canvas_w / cell_total; - let zoom_y = view.last_canvas_h / cell_total; + let zoom_x = avail_w / cell_total; + let zoom_y = avail_h / cell_total; view.zoom = zoom_x.min(zoom_y).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); - view.pan_x = (view.last_canvas_w - cell_total * view.zoom) * 0.5; - view.pan_y = (view.last_canvas_h - cell_total * view.zoom) * 0.5; + view.pan_x = (avail_w - cell_total * view.zoom) * 0.5; + view.pan_y = (avail_h - cell_total * view.zoom) * 0.5; + view.needs_fit = false; view.invalidate_cache(); - None + Some(format!("Fit to window: {:.2}×", view.zoom)) } TileGridCanvasMessage::EditSelected(new_desc) => { if let Some(idx) = view.selected_cell { let word = encode_descriptor(new_desc); if (idx as usize) < grid.data.len() { + // TODO(bug: no-edit-history): EditSelected mutates grid.data + // directly without recording in EditHistory, so undo/redo + // won't work for canvas edits. Need to push a + // (cell_index, old_word, new_word) entry to the standard + // editor's EditHistory before mutating. grid.data[idx as usize] = word; + // Keep synthetic metadata in sync — payload_length and grid_size + // are not on-disk fields for `.dat` but are written inside to_writer. + let new_len = grid.data.len() as u32 * 4; + grid.payload_length = new_len; + grid.grid_size = (grid.data.len() as f64).sqrt() as u32; view.invalidate_cache(); return Some(format!("Edited cell {idx}")); } + return Some(format!("Cell {idx} out of range (grid has {})", grid.data.len())); } Some("No cell selected".to_string()) } TileGridCanvasMessage::ClearSelection => { view.selected_cell = None; + Some("Selection cleared".to_string()) + } + TileGridCanvasMessage::PaneResized(event) => { + if let Some(ref mut ps) = view.pane_state { + ps.resize(event.split, event.ratio); + } + None + } + TileGridCanvasMessage::PaneClicked(pane) => { + view.pane_focus = Some(pane); + None + } + TileGridCanvasMessage::EditFieldBuffer { field, value } => { + view.edit_buffers.insert(field, value); None } - TileGridCanvasMessage::ToggleRawView => None, // handled by view + TileGridCanvasMessage::CommitFieldBuffer { field } => { + if let Some(buf) = view.edit_buffers.remove(&field) + && let Ok(v) = buf.parse::() + && let Some(idx) = view.selected_cell + && let Some(word) = grid.data.get(idx as usize).copied() + { + let mut desc = TileDescriptor::from_word(word); + apply_field_to_desc(&mut desc, &field, v); + if (idx as usize) < grid.data.len() { + let old_word = grid.data[idx as usize]; + let new_word = encode_descriptor(desc); + if new_word != old_word { + grid.data[idx as usize] = new_word; + view.invalidate_cache(); + } + } + } + None + } + } +} + +/// Apply a clamped value to the correct field of a `TileDescriptor` based on the field name. +fn apply_field_to_desc(d: &mut TileDescriptor, field: &str, v: i32) { + if field == "object_id" { + d.object_id = v.clamp(0, 1023) as u16; + } else if field == "tile_index" { + d.tile_index = v.clamp(0, 32767) as u16; + } else if field == "render_flags" { + d.render_flags = v.clamp(0, 3) as u8; + } else if field == "reserved" { + d.reserved = v.clamp(0, 31) as u8; } } diff --git a/dispel-gui/src/editors/tile_grid/view.rs b/dispel-gui/src/editors/tile_grid/view.rs index 7f3097e5..29be199c 100644 --- a/dispel-gui/src/editors/tile_grid/view.rs +++ b/dispel-gui/src/editors/tile_grid/view.rs @@ -2,40 +2,63 @@ use super::canvas::TileGridCanvas; use super::message::TileGridCanvasMessage; -use super::state::TileGridViewState; +use super::state::{PaneContent, TileGridViewState}; use crate::message::{Message, MessageExt}; use dispel_core::references::tile_grid::{TileDescriptor, TileGrid}; -use iced::widget::{canvas, column, container, row, scrollable, text, text_input}; +use iced::widget::{button, canvas, column, pane_grid, row, scrollable, text, text_input}; use iced::{Element, Length}; -/// Render the tile grid canvas view: a pan/zoom canvas on the left, -/// an inspector panel on the right for editing the selected cell. +/// Render the tile grid canvas view: a resizable pane grid with the canvas +/// on the left and an inspector panel on the right. pub fn view_canvas<'a>( grid: &'a TileGrid, view: &'a TileGridViewState, - status: Option<&'a str>, ) -> Element<'a, Message> { - let canvas_widget = canvas(TileGridCanvas { grid, view }) - .width(Length::Fill) - .height(Length::Fill); - - let inspector = inspector_panel(grid, view, status); + // The pane_grid widget needs a reference to a State. We fall back to + // a transient local State if the persistent one is missing — the user + // won't be able to resize in that case, but the layout still renders. + let pane_state_ref: &pane_grid::State = match view.pane_state.as_ref() { + Some(s) => s, + None => { + // Leak a static State so the reference is valid for 'a. + Box::leak(Box::new(pane_grid::State::new(PaneContent::Canvas).0)) + } + }; - row![ - container(canvas_widget) - .width(Length::FillPortion(3)) - .height(Length::Fill), - container(scrollable(inspector)) - .width(Length::FillPortion(1)) - .height(Length::Fill), - ] + pane_grid::PaneGrid::new(pane_state_ref, |_pane, content, _is_maximized| { + let inner: Element = match content { + PaneContent::Canvas => canvas(TileGridCanvas { grid, view }) + .width(Length::Fill) + .height(Length::Fill) + .into(), + PaneContent::Inspector => { + let inspector = inspector_panel(grid, view); + scrollable(inspector) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + }; + // Wrap in `pane_grid::Content` so the pane_grid forwards mouse + // events (ButtonPressed/CursorMoved/WheelScrolled) to the child + // instead of capturing them for resize-handle logic. Content::new + // already provides a container with `Length::Fill` sizing, so we + // must NOT wrap the inner element in another container — double + // wrapping collapses the layout to zero size. + pane_grid::Content::new(inner) + }) + .on_resize(10, |event| { + Message::tile_grid_canvas(TileGridCanvasMessage::PaneResized(event)) + }) + .on_click(|pane| { + Message::tile_grid_canvas(TileGridCanvasMessage::PaneClicked(pane)) + }) .into() } fn inspector_panel<'a>( grid: &'a TileGrid, - view: &TileGridViewState, - status: Option<&'a str>, + view: &'a TileGridViewState, ) -> Element<'a, Message> { let mut col = column![text("Inspector").size(18)].spacing(6).padding(10); @@ -59,28 +82,36 @@ fn inspector_panel<'a>( .push(text(format!("Grid coords: ({gx}, {gy})"))) .push(text(format!("Raw word: 0x{:08X}", word_from_desc(desc)))); - let fields: [(&str, i32); 4] = [ - ("Object ID (0–1023):", desc.object_id as i32), - ("Tile Index (0–32767):", desc.tile_index as i32), - ("Render Flags (0–3):", desc.render_flags as i32), - ("Reserved (0–31):", desc.reserved as i32), + let fields: [(&str, &str, i32); 4] = [ + ("object_id", "Object ID (0–1023):", desc.object_id as i32), + ("tile_index", "Tile Index (0–32767):", desc.tile_index as i32), + ("render_flags", "Render Flags (0–3):", desc.render_flags as i32), + ("reserved", "Reserved (0–31):", desc.reserved as i32), ]; - for (label, value) in fields { - let d = desc; - let value_str = value.to_string(); + for (field, label, value) in fields { + // Use the edit buffer if present, else the live value. + // This prevents the "clamped value mid-edit" bug: typing "999" + // shows "999" in the field, not "1023" until the user commits. + let display = view + .edit_buffers + .get(field) + .cloned() + .unwrap_or_else(|| value.to_string()); col = col.push(text(label).size(12)); col = col.push( - text_input("", value_str) + text_input("", display) .on_input(move |s| { - if let Ok(v) = s.parse::() { - let mut new_desc = d; - apply_field(&mut new_desc, label, v); - Message::tile_grid_canvas(TileGridCanvasMessage::EditSelected(new_desc)) - } else { - Message::tile_grid_canvas(TileGridCanvasMessage::ClearSelection) - } + Message::tile_grid_canvas(TileGridCanvasMessage::EditFieldBuffer { + field: field.to_string(), + value: s, + }) }) + .on_submit(Message::tile_grid_canvas( + TileGridCanvasMessage::CommitFieldBuffer { + field: field.to_string(), + }, + )) .width(Length::Fill), ); } @@ -88,10 +119,24 @@ fn inspector_panel<'a>( col = col.push(text("No cell selected. Click a cell to inspect.")); } - if let Some(s) = status { + if let Some(s) = view.last_status.as_deref() { col = col.push(text(s).size(12)); } + col = col + .push(text(format!("Zoom: {:.2}×", view.zoom)).size(12)) + .push( + row![ + button("Fit").on_press(Message::tile_grid_canvas( + TileGridCanvasMessage::FitToWindow + )), + button("1:1").on_press(Message::tile_grid_canvas( + TileGridCanvasMessage::SetZoom(1.0) + )), + ] + .spacing(4), + ); + col.into() } @@ -101,16 +146,3 @@ fn word_from_desc(d: TileDescriptor) -> u32 { | ((d.render_flags as u32 & 0x03) << 25) | ((d.reserved as u32 & 0x1f) << 27) } - -/// Apply a clamped value to the correct field of a `TileDescriptor` based on the label. -fn apply_field(d: &mut TileDescriptor, label: &str, v: i32) { - if label.starts_with("Object ID") { - d.object_id = v.clamp(0, 1023) as u16; - } else if label.starts_with("Tile Index") { - d.tile_index = v.clamp(0, 32767) as u16; - } else if label.starts_with("Render Flags") { - d.render_flags = v.clamp(0, 3) as u8; - } else if label.starts_with("Reserved") { - d.reserved = v.clamp(0, 31) as u8; - } -} diff --git a/dispel-gui/src/editors/tile_grid_canvas/mod.rs b/dispel-gui/src/editors/tile_grid_canvas/mod.rs index 3dadd6d0..5a8f4f8a 100644 --- a/dispel-gui/src/editors/tile_grid_canvas/mod.rs +++ b/dispel-gui/src/editors/tile_grid_canvas/mod.rs @@ -1,14 +1,29 @@ -//! Dispatch wrapper for tile grid canvas messages. +//! Dispatch wrapper for tile grid canvas messages and view. //! -//! This module exists so the `define_update_dispatch!` macro can route -//! `EditorMessage::TileGridCanvas` to a `handle` function without colliding -//! with the standard `handle` generated by `define_standard_editor!` for -//! `StandardEditorMessage`. +//! This module exists so the `define_update_dispatch!` and +//! `define_view_dispatch!` macros can route `EditorMessage::TileGridCanvas` +//! and `EditorType::TileGridEditor` to the canvas-based handler/view +//! without colliding with the standard spreadsheet functions generated by +//! `define_standard_editor!` for `StandardEditorMessage`. -use crate::editors::tile_grid::{self, TileGridCanvasMessage}; +use crate::editors::tile_grid::{self, TileGridCanvasMessage, TileGridViewState}; use crate::message::Message; use iced::Task; pub fn handle(msg: TileGridCanvasMessage, app: &mut crate::app::App) -> Task { tile_grid::handle_canvas_message(msg, app) } + +/// Render the tile grid canvas view. Reads the loaded grid from +/// `tile_grid_editor` and the pan/zoom state from `tile_grid_view`. +pub fn view(app: &crate::app::App) -> iced::Element<'_, Message> { + let view: &TileGridViewState = &app.state.editors.tile_grid_view; + if let Some(catalog) = app.state.editors.tile_grid_editor.state.catalog.as_ref() + && let Some(grid) = catalog.first() + { + return tile_grid::view::view_canvas(grid, view); + } + // Fallback: show a "no grid loaded" message + use iced::widget::{center, text}; + center(text("No tile grid loaded. Open a .dat file from the file tree.").size(18)).into() +} diff --git a/dispel-macros/src/record_patcher_impl.rs b/dispel-macros/src/record_patcher_impl.rs index 52dbba3a..50aca587 100644 --- a/dispel-macros/src/record_patcher_impl.rs +++ b/dispel-macros/src/record_patcher_impl.rs @@ -180,7 +180,7 @@ pub fn expand(input: DeriveInput) -> TokenStream2 { ))); } }, - FieldInfo::Nested { ident: _, .. } => quote! { + FieldInfo::Nested { .. } => quote! { #field_name => { return Err(crate::modding::error::ModdingError::Malformed(format!( "{}.{} is nested and cannot be patched", From f8704cd01b6c9096937e1f367ad96a21e64b4444 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:24:52 +0200 Subject: [PATCH 44/49] Remove not existing test --- src/map/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/map/mod.rs b/src/map/mod.rs index 27cd43f2..280d5d25 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1137,9 +1137,6 @@ mod shadow_tests { } } -#[cfg(test)] -mod render_placement_tests; - #[cfg(test)] mod db_export_tests { use super::*; From c921ef7e936002c95e48e90aca43127894f40a49 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:30:14 +0200 Subject: [PATCH 45/49] Format code --- dispel-gui/src/app.rs | 20 +++---- dispel-gui/src/components/editable.rs | 4 +- dispel-gui/src/dispatch_table.rs | 4 +- .../src/editors/localization_manager/view.rs | 4 +- dispel-gui/src/editors/mod.rs | 10 ++-- dispel-gui/src/editors/tile_grid/canvas.rs | 20 +++++-- dispel-gui/src/editors/tile_grid/component.rs | 2 +- dispel-gui/src/editors/tile_grid/message.rs | 9 +-- dispel-gui/src/editors/tile_grid/mod.rs | 33 +++++------ dispel-gui/src/editors/tile_grid/state.rs | 24 ++++---- dispel-gui/src/editors/tile_grid/tests.rs | 58 +++++++++++-------- dispel-gui/src/editors/tile_grid/update.rs | 55 ++++++++++++------ dispel-gui/src/editors/tile_grid/view.rs | 45 +++++++------- dispel-gui/src/message/editor/mod.rs | 2 +- gui-widgets/src/components/mod.rs | 2 +- gui-widgets/src/components/text_area.rs | 35 +++++++++-- gui-widgets/src/lib.rs | 2 +- src/commands/registry/mod.rs | 33 +++++++---- 18 files changed, 214 insertions(+), 148 deletions(-) diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs index ee45dd65..6f656542 100644 --- a/dispel-gui/src/app.rs +++ b/dispel-gui/src/app.rs @@ -372,12 +372,13 @@ impl App { let path_buf = path.to_path_buf(); Task::perform( async move { - dispel_core::ChestSpawn::read_file(&path_buf) - .map_err(|e| e.to_string()) + dispel_core::ChestSpawn::read_file(&path_buf).map_err(|e| e.to_string()) }, |result| { crate::message::Message::chest_spawn( - crate::editors::chest_spawn::ChestSpawnEditorMessage::CatalogLoaded(result), + crate::editors::chest_spawn::ChestSpawnEditorMessage::CatalogLoaded( + result, + ), ) }, ) @@ -386,12 +387,13 @@ impl App { let path_buf = path.to_path_buf(); Task::perform( async move { - dispel_core::MonsterSpawn::read_file(&path_buf) - .map_err(|e| e.to_string()) + dispel_core::MonsterSpawn::read_file(&path_buf).map_err(|e| e.to_string()) }, |result| { crate::message::Message::monster_spawn( - crate::editors::monster_spawn::MonsterSpawnEditorMessage::CatalogLoaded(result), + crate::editors::monster_spawn::MonsterSpawnEditorMessage::CatalogLoaded( + result, + ), ) }, ) @@ -400,8 +402,7 @@ impl App { let path_buf = path.to_path_buf(); Task::perform( async move { - dispel_core::MapInfoMulti::read_file(&path_buf) - .map_err(|e| e.to_string()) + dispel_core::MapInfoMulti::read_file(&path_buf).map_err(|e| e.to_string()) }, |result| { crate::message::Message::map_info_multi( @@ -417,8 +418,7 @@ impl App { // without requiring the user to click "Fit" or resize. let load_task = Task::perform( async move { - dispel_core::TileGrid::read_file(&path_buf) - .map_err(|e| e.to_string()) + dispel_core::TileGrid::read_file(&path_buf).map_err(|e| e.to_string()) }, |result| { crate::message::Message::tile_grid( diff --git a/dispel-gui/src/components/editable.rs b/dispel-gui/src/components/editable.rs index 983f163f..ad3f75d1 100644 --- a/dispel-gui/src/components/editable.rs +++ b/dispel-gui/src/components/editable.rs @@ -312,7 +312,9 @@ pub fn format_vec_u32(values: &[u32]) -> String { /// Returns `None` if any value fails to parse. #[inline] pub fn parse_vec_u32(s: &str) -> Option> { - s.split(',').map(|part| part.trim().parse::().ok()).collect() + s.split(',') + .map(|part| part.trim().parse::().ok()) + .collect() } // ── editable_record_fields! macro ───────────────────────────────────────────── diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs index ceafb7b2..8d5fd9bc 100644 --- a/dispel-gui/src/dispatch_table.rs +++ b/dispel-gui/src/dispatch_table.rs @@ -261,9 +261,7 @@ pub fn spreadsheet_nav_msg( MapInfoMultiEditor => { Message::map_info_multi(map_info_multi::MapInfoMultiEditorMessage::Spreadsheet(sm)) } - TileGridEditor => { - Message::tile_grid(tile_grid::TileGridEditorMessage::Spreadsheet(sm)) - } + TileGridEditor => Message::tile_grid(tile_grid::TileGridEditorMessage::Spreadsheet(sm)), _ => return None, }) } diff --git a/dispel-gui/src/editors/localization_manager/view.rs b/dispel-gui/src/editors/localization_manager/view.rs index 0e7ecd3d..0783d247 100644 --- a/dispel-gui/src/editors/localization_manager/view.rs +++ b/dispel-gui/src/editors/localization_manager/view.rs @@ -4,7 +4,7 @@ use crate::editors::localization_manager::LocalizationMessage; use crate::message::{Message, MessageExt}; use crate::style; use gui_widgets::lucide::{LUCIDE_FONT, icon_char}; -use gui_widgets::textarea; +use gui_widgets::textarea_fill; use iced::widget::{ button, checkbox, column, container, pick_list, progress_bar, row, scrollable, text, text_input, }; @@ -311,7 +311,7 @@ pub fn view(app: &App) -> Element<'_, Message> { }); let translation_label = text("Translation").size(11).style(style::subtle_text); - let translation_editor = textarea(&state.translation_content.0, |action| { + let translation_editor = textarea_fill(&state.translation_content.0, |action| { Message::localization(LocalizationMessage::TranslationAction(action)) }); diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs index e39b478d..e2b3b61a 100644 --- a/dispel-gui/src/editors/mod.rs +++ b/dispel-gui/src/editors/mod.rs @@ -9,6 +9,7 @@ pub mod all_map_ini; pub mod chdata; +pub mod chest_spawn; pub mod db_viewer; pub mod dialogue_paragraph; pub mod dialogue_script; @@ -26,6 +27,7 @@ pub mod hex_wrapper; pub mod localization_manager; pub mod magic; pub mod map_editor; +pub mod map_info_multi; pub mod map_ini; pub mod message_scr; pub mod misc_item; @@ -33,6 +35,7 @@ pub mod mod_packager; pub mod monster; pub mod monster_ini; pub mod monster_ref; +pub mod monster_spawn; pub mod npc_ini; pub mod npc_ref; pub mod party_ini; @@ -44,11 +47,8 @@ pub mod save_ifo; pub mod snf_editor; pub mod sprite_editor; pub mod store; -pub mod tileset; -pub mod wave_ini; -pub mod map_info_multi; pub mod tile_grid; pub mod tile_grid_canvas; +pub mod tileset; +pub mod wave_ini; pub mod weapon; -pub mod chest_spawn; -pub mod monster_spawn; diff --git a/dispel-gui/src/editors/tile_grid/canvas.rs b/dispel-gui/src/editors/tile_grid/canvas.rs index f07fa2b5..5a2bad8f 100644 --- a/dispel-gui/src/editors/tile_grid/canvas.rs +++ b/dispel-gui/src/editors/tile_grid/canvas.rs @@ -51,23 +51,31 @@ impl<'a> canvas::Program for TileGridCanvas<'a> { state.dragging = true; state.did_drag = false; state.last_pos = Some(pos); - return Some(Action::publish(Message::tile_grid_canvas( - TileGridCanvasMessage::Click(pos.x, pos.y), - ))); + return None; } } Event::Mouse(MouseEvent::ButtonReleased(mouse::Button::Left)) => { + let did_drag = state.did_drag; state.did_drag = false; state.dragging = false; state.last_pos = None; + if !did_drag { + if let Some(pos) = cursor.position_in(bounds) { + return Some(Action::publish(Message::tile_grid_canvas( + TileGridCanvasMessage::Click(pos.x, pos.y), + ))); + } + } } Event::Mouse(MouseEvent::CursorMoved { .. }) => { if let Some(pos) = cursor.position_in(bounds) { if state.dragging { - state.did_drag = true; if let Some(last) = state.last_pos { let dx = pos.x - last.x; let dy = pos.y - last.y; + if dx != 0.0 || dy != 0.0 { + state.did_drag = true; + } state.last_pos = Some(pos); return Some(Action::publish(Message::tile_grid_canvas( TileGridCanvasMessage::Pan(dx, dy), @@ -170,7 +178,7 @@ fn draw_grid(frame: &mut Frame, grid: &TileGrid, view: &TileGridViewState) { fn cell_color(object_id: u16) -> Color { match object_id { 0 => Color::from_rgb(0.15, 0.15, 0.15), // empty: dark - 1 => Color::from_rgb(0.6, 0.6, 0.6), // wall: light gray - _ => Color::from_rgb(0.3, 0.5, 0.9), // other: blue + 1 => Color::from_rgb(0.6, 0.6, 0.6), // wall: light gray + _ => Color::from_rgb(0.3, 0.5, 0.9), // other: blue } } diff --git a/dispel-gui/src/editors/tile_grid/component.rs b/dispel-gui/src/editors/tile_grid/component.rs index 0ddb6802..ac16667c 100644 --- a/dispel-gui/src/editors/tile_grid/component.rs +++ b/dispel-gui/src/editors/tile_grid/component.rs @@ -1,7 +1,7 @@ use crate::components::editable::EditableRecord; use crate::editable_record_fields; -use dispel_core::references::tile_grid::TileDescriptor; use dispel_core::TileGrid; +use dispel_core::references::tile_grid::TileDescriptor; editable_record_fields!(TileGrid, { { id = Integer / "ID:" }, diff --git a/dispel-gui/src/editors/tile_grid/message.rs b/dispel-gui/src/editors/tile_grid/message.rs index da9b85af..362eebaf 100644 --- a/dispel-gui/src/editors/tile_grid/message.rs +++ b/dispel-gui/src/editors/tile_grid/message.rs @@ -33,13 +33,8 @@ pub enum TileGridCanvasMessage { /// Update the local edit buffer for a field without committing to /// `grid.data`. Used so the user can type "999" and see "999" before /// it gets clamped on commit (Enter/blur). - EditFieldBuffer { - field: String, - value: String, - }, + EditFieldBuffer { field: String, value: String }, /// Commit the edit buffer for a field to `grid.data` (called on Enter /// or blur). If the buffer is empty or invalid, the field is ignored. - CommitFieldBuffer { - field: String, - }, + CommitFieldBuffer { field: String }, } diff --git a/dispel-gui/src/editors/tile_grid/mod.rs b/dispel-gui/src/editors/tile_grid/mod.rs index 5da3581e..b0c402c3 100644 --- a/dispel-gui/src/editors/tile_grid/mod.rs +++ b/dispel-gui/src/editors/tile_grid/mod.rs @@ -35,10 +35,10 @@ pub fn handle_canvas_message( let view = &app.state.editors.tile_grid_view; let catalog = &app.state.editors.tile_grid_editor.state.catalog; let old = if let TileGridCanvasMessage::EditSelected(_) = &msg { - catalog - .as_ref() - .and_then(|c| c.first()) - .and_then(|g| view.selected_cell.and_then(|idx| g.data.get(idx as usize).copied())) + catalog.as_ref().and_then(|c| c.first()).and_then(|g| { + view.selected_cell + .and_then(|idx| g.data.get(idx as usize).copied()) + }) } else { None }; @@ -65,20 +65,17 @@ pub fn handle_canvas_message( // Step 3: push to EditHistory (mutable borrow, now safe since the // catalog borrow from step 2 is dropped). if let (Some(old), Some(new_desc)) = (old_word, new_desc_for_history) { - let new_word = update::encode_descriptor(new_desc); - if new_word != old { - app.state - .editors - .tile_grid_editor - .state - .edit_history - .push(crate::components::edit_history::EditAction::FieldChange { - record_idx: selected_idx.unwrap_or(0) as usize, - field: format!("cell_{}", selected_idx.unwrap_or(0)), - old_value: old.to_string(), - new_value: new_word.to_string(), - }); - } + let new_word = update::encode_descriptor(new_desc); + if new_word != old { + app.state.editors.tile_grid_editor.state.edit_history.push( + crate::components::edit_history::EditAction::FieldChange { + record_idx: selected_idx.unwrap_or(0) as usize, + field: format!("cell_{}", selected_idx.unwrap_or(0)), + old_value: old.to_string(), + new_value: new_word.to_string(), + }, + ); + } } if let Some(s) = status { diff --git a/dispel-gui/src/editors/tile_grid/state.rs b/dispel-gui/src/editors/tile_grid/state.rs index 439e1b90..23ad2dc3 100644 --- a/dispel-gui/src/editors/tile_grid/state.rs +++ b/dispel-gui/src/editors/tile_grid/state.rs @@ -43,11 +43,9 @@ pub struct TileGridViewState { pub pane_state: Option>, /// Last focused pane in the pane grid. pub pane_focus: Option, - /// Per-field edit buffers. Keyed by field name (e.g. "object_id"). - /// When `Some`, the inspector shows the buffer text instead of the live - /// `grid.data` value, so the user can type "999" and see "999" before - /// it gets clamped on commit. - pub edit_buffers: HashMap, + /// Per-cell, per-field edit buffers. Keyed by `(cell_index, field_name)` + /// so switching cells never carries a wrong "999" value. + pub edit_buffers: HashMap<(u32, String), String>, } /// Identifies which pane the user is looking at. @@ -61,13 +59,16 @@ pub enum PaneContent { impl Default for TileGridViewState { fn default() -> Self { - let (mut pane_state, canvas_pane) = - pane_grid::State::new(PaneContent::Canvas); + let (mut pane_state, canvas_pane) = pane_grid::State::new(PaneContent::Canvas); // Split the canvas pane vertically to place the inspector below // the canvas. In `pane_grid::Axis`, `Vertical` means the split // line runs top-to-bottom, so the new pane appears beneath. let (inspector_pane, split) = pane_state - .split(pane_grid::Axis::Vertical, canvas_pane, PaneContent::Inspector) + .split( + pane_grid::Axis::Vertical, + canvas_pane, + PaneContent::Inspector, + ) .expect("split on fresh pane_grid should always succeed"); // Set the split so the inspector (bottom pane) takes ~30% of the // vertical space, with the canvas taking the remaining ~70%. @@ -117,12 +118,7 @@ impl TileGridViewState { /// Convert canvas-local pixel coords to a flat cell index. /// Returns `None` if the position is outside the grid. - pub fn pixel_to_cell( - &self, - cx: f32, - cy: f32, - grid_size: u32, - ) -> Option { + pub fn pixel_to_cell(&self, cx: f32, cy: f32, grid_size: u32) -> Option { let world_x = (cx - self.pan_x) / (CELL_SIZE * self.zoom); let world_y = (cy - self.pan_y) / (CELL_SIZE * self.zoom); if world_x < 0.0 || world_y < 0.0 { diff --git a/dispel-gui/src/editors/tile_grid/tests.rs b/dispel-gui/src/editors/tile_grid/tests.rs index faac912a..a4ab4ad5 100644 --- a/dispel-gui/src/editors/tile_grid/tests.rs +++ b/dispel-gui/src/editors/tile_grid/tests.rs @@ -89,11 +89,7 @@ fn clamp_zoom_bounds() { fn pan_message_updates_state() { let mut v = TileGridViewState::default(); let mut g = TileGrid::default(); - let status = super::update::handle( - TileGridCanvasMessage::Pan(10.0, 20.0), - &mut v, - &mut g, - ); + let status = super::update::handle(TileGridCanvasMessage::Pan(10.0, 20.0), &mut v, &mut g); assert_eq!(v.pan_x, 10.0); assert_eq!(v.pan_y, 20.0); assert!(status.is_none()); @@ -133,10 +129,22 @@ fn zoom_to_cursor_keeps_world_point_stationary() { // old_zoom = 1.0, new_zoom = 2.0 // world_x = (100 - 0) / (8 * 1) = 12.5 // pan_x_new = 100 - 12.5 * 16 = 100 - 200 = -100 - super::update::handle(TileGridCanvasMessage::Zoom(2.0, 100.0, 100.0), &mut v, &mut g); + super::update::handle( + TileGridCanvasMessage::Zoom(2.0, 100.0, 100.0), + &mut v, + &mut g, + ); assert_eq!(v.zoom, 2.0); - assert!((v.pan_x - (-100.0)).abs() < 0.01, "pan_x should be -100, got {}", v.pan_x); - assert!((v.pan_y - (-100.0)).abs() < 0.01, "pan_y should be -100, got {}", v.pan_y); + assert!( + (v.pan_x - (-100.0)).abs() < 0.01, + "pan_x should be -100, got {}", + v.pan_x + ); + assert!( + (v.pan_y - (-100.0)).abs() < 0.01, + "pan_y should be -100, got {}", + v.pan_y + ); } #[test] @@ -156,10 +164,22 @@ fn zoom_to_cursor_with_existing_pan() { // Cursor at (150, 150), zoom in 2× // world_x = (150 - (-50)) / 8 = 200/8 = 25.0 // pan_x_new = 150 - 25.0 * 16 = 150 - 400 = -250 - super::update::handle(TileGridCanvasMessage::Zoom(2.0, 150.0, 150.0), &mut v, &mut g); + super::update::handle( + TileGridCanvasMessage::Zoom(2.0, 150.0, 150.0), + &mut v, + &mut g, + ); assert_eq!(v.zoom, 2.0); - assert!((v.pan_x - (-250.0)).abs() < 0.01, "pan_x should be -250, got {}", v.pan_x); - assert!((v.pan_y - (-250.0)).abs() < 0.01, "pan_y should be -250, got {}", v.pan_y); + assert!( + (v.pan_x - (-250.0)).abs() < 0.01, + "pan_x should be -250, got {}", + v.pan_x + ); + assert!( + (v.pan_y - (-250.0)).abs() < 0.01, + "pan_y should be -250, got {}", + v.pan_y + ); } #[test] @@ -170,11 +190,7 @@ fn click_message_selects_cell() { ..Default::default() }; // Click at pixel (16, 16) → cell (2, 2) → flat index 2*249+2 = 500 - let status = super::update::handle( - TileGridCanvasMessage::Click(16.0, 16.0), - &mut v, - &mut g, - ); + let status = super::update::handle(TileGridCanvasMessage::Click(16.0, 16.0), &mut v, &mut g); assert_eq!(v.selected_cell, Some(500)); assert!(status.unwrap().contains("Selected cell")); } @@ -205,11 +221,7 @@ fn hover_message_sets_hovered_cell() { grid_size: 249, ..Default::default() }; - super::update::handle( - TileGridCanvasMessage::Hover(0.0, 0.0), - &mut v, - &mut g, - ); + super::update::handle(TileGridCanvasMessage::Hover(0.0, 0.0), &mut v, &mut g); assert_eq!(v.hovered_cell, Some(0)); } @@ -261,10 +273,10 @@ fn edit_selected_without_selection_returns_error() { fn encode_descriptor_roundtrip() { // Encode then decode should recover the same fields let d = TileDescriptor { - object_id: 0x03ff, // max 10-bit + object_id: 0x03ff, // max 10-bit tile_index: 0x7fff, // max 15-bit render_flags: 0x03, // max 2-bit - reserved: 0x1f, // max 5-bit + reserved: 0x1f, // max 5-bit }; let word = super::update::encode_descriptor(d); let decoded = TileDescriptor::from_word(word); diff --git a/dispel-gui/src/editors/tile_grid/update.rs b/dispel-gui/src/editors/tile_grid/update.rs index f76206f7..83526cc9 100644 --- a/dispel-gui/src/editors/tile_grid/update.rs +++ b/dispel-gui/src/editors/tile_grid/update.rs @@ -32,7 +32,8 @@ fn handle_inner( } TileGridCanvasMessage::Zoom(factor, cx, cy) => { let old_zoom = view.zoom; - let new_zoom = (view.zoom * factor).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); + let new_zoom = + (view.zoom * factor).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); if (new_zoom - old_zoom).abs() > f32::EPSILON { // Zoom-to-cursor: keep the world point under (cx, cy) stationary. // world_x = (cx - pan_x) / (CELL_SIZE * old_zoom) @@ -97,7 +98,9 @@ fn handle_inner( let cell_total = gs * super::state::CELL_SIZE; let zoom_x = avail_w / cell_total; let zoom_y = avail_h / cell_total; - view.zoom = zoom_x.min(zoom_y).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); + view.zoom = zoom_x + .min(zoom_y) + .clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM); view.pan_x = (avail_w - cell_total * view.zoom) * 0.5; view.pan_y = (avail_h - cell_total * view.zoom) * 0.5; view.needs_fit = false; @@ -122,7 +125,10 @@ fn handle_inner( view.invalidate_cache(); return Some(format!("Edited cell {idx}")); } - return Some(format!("Cell {idx} out of range (grid has {})", grid.data.len())); + return Some(format!( + "Cell {idx} out of range (grid has {})", + grid.data.len() + )); } Some("No cell selected".to_string()) } @@ -141,25 +147,36 @@ fn handle_inner( None } TileGridCanvasMessage::EditFieldBuffer { field, value } => { - view.edit_buffers.insert(field, value); + if let Some(idx) = view.selected_cell { + view.edit_buffers.insert((idx, field.clone()), value); + } None } TileGridCanvasMessage::CommitFieldBuffer { field } => { - if let Some(buf) = view.edit_buffers.remove(&field) - && let Ok(v) = buf.parse::() - && let Some(idx) = view.selected_cell - && let Some(word) = grid.data.get(idx as usize).copied() - { - let mut desc = TileDescriptor::from_word(word); - apply_field_to_desc(&mut desc, &field, v); - if (idx as usize) < grid.data.len() { - let old_word = grid.data[idx as usize]; - let new_word = encode_descriptor(desc); - if new_word != old_word { - grid.data[idx as usize] = new_word; - view.invalidate_cache(); - } - } + if let Some(idx) = view.selected_cell { + let buf_opt = view.edit_buffers.remove(&(idx, field.clone())); + if buf_opt.is_none() { + return Some(format!("No edit buffer for '{field}' on cell {idx}")); + } + let buf = buf_opt.unwrap(); + if buf.trim().is_empty() { + return Some("Empty value — nothing to commit".to_string()); + } + if let Ok(v) = buf.parse::() + && let Some(word) = grid.data.get(idx as usize).copied() + { + let mut desc = TileDescriptor::from_word(word); + apply_field_to_desc(&mut desc, &field, v); + if (idx as usize) < grid.data.len() { + let old_word = grid.data[idx as usize]; + let new_word = encode_descriptor(desc); + if new_word != old_word { + grid.data[idx as usize] = new_word; + view.invalidate_cache(); + } + } + return Some(format!("Updated '{field}' to {v}")); + } } None } diff --git a/dispel-gui/src/editors/tile_grid/view.rs b/dispel-gui/src/editors/tile_grid/view.rs index 29be199c..d2b70547 100644 --- a/dispel-gui/src/editors/tile_grid/view.rs +++ b/dispel-gui/src/editors/tile_grid/view.rs @@ -10,10 +10,7 @@ use iced::{Element, Length}; /// Render the tile grid canvas view: a resizable pane grid with the canvas /// on the left and an inspector panel on the right. -pub fn view_canvas<'a>( - grid: &'a TileGrid, - view: &'a TileGridViewState, -) -> Element<'a, Message> { +pub fn view_canvas<'a>(grid: &'a TileGrid, view: &'a TileGridViewState) -> Element<'a, Message> { // The pane_grid widget needs a reference to a State. We fall back to // a transient local State if the persistent one is missing — the user // won't be able to resize in that case, but the layout still renders. @@ -50,16 +47,11 @@ pub fn view_canvas<'a>( .on_resize(10, |event| { Message::tile_grid_canvas(TileGridCanvasMessage::PaneResized(event)) }) - .on_click(|pane| { - Message::tile_grid_canvas(TileGridCanvasMessage::PaneClicked(pane)) - }) + .on_click(|pane| Message::tile_grid_canvas(TileGridCanvasMessage::PaneClicked(pane))) .into() } -fn inspector_panel<'a>( - grid: &'a TileGrid, - view: &'a TileGridViewState, -) -> Element<'a, Message> { +fn inspector_panel<'a>(grid: &'a TileGrid, view: &'a TileGridViewState) -> Element<'a, Message> { let mut col = column![text("Inspector").size(18)].spacing(6).padding(10); if let Some(idx) = view.selected_cell { @@ -84,8 +76,16 @@ fn inspector_panel<'a>( let fields: [(&str, &str, i32); 4] = [ ("object_id", "Object ID (0–1023):", desc.object_id as i32), - ("tile_index", "Tile Index (0–32767):", desc.tile_index as i32), - ("render_flags", "Render Flags (0–3):", desc.render_flags as i32), + ( + "tile_index", + "Tile Index (0–32767):", + desc.tile_index as i32, + ), + ( + "render_flags", + "Render Flags (0–3):", + desc.render_flags as i32, + ), ("reserved", "Reserved (0–31):", desc.reserved as i32), ]; @@ -93,11 +93,14 @@ fn inspector_panel<'a>( // Use the edit buffer if present, else the live value. // This prevents the "clamped value mid-edit" bug: typing "999" // shows "999" in the field, not "1023" until the user commits. - let display = view - .edit_buffers - .get(field) - .cloned() - .unwrap_or_else(|| value.to_string()); + let display = if let Some(idx) = view.selected_cell { + view.edit_buffers + .get(&(idx, field.to_string())) + .cloned() + .unwrap_or_else(|| value.to_string()) + } else { + value.to_string() + }; col = col.push(text(label).size(12)); col = col.push( text_input("", display) @@ -130,9 +133,9 @@ fn inspector_panel<'a>( button("Fit").on_press(Message::tile_grid_canvas( TileGridCanvasMessage::FitToWindow )), - button("1:1").on_press(Message::tile_grid_canvas( - TileGridCanvasMessage::SetZoom(1.0) - )), + button("1:1").on_press(Message::tile_grid_canvas(TileGridCanvasMessage::SetZoom( + 1.0 + ))), ] .spacing(4), ); diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs index e215b000..2da48730 100644 --- a/dispel-gui/src/message/editor/mod.rs +++ b/dispel-gui/src/message/editor/mod.rs @@ -42,8 +42,8 @@ use crate::editors::tileset::TilesetEditorMessage; use crate::editors::wave_ini::WaveIniEditorMessage; use crate::editors::chest_spawn::ChestSpawnEditorMessage; -use crate::editors::monster_spawn::MonsterSpawnEditorMessage; use crate::editors::map_info_multi::MapInfoMultiEditorMessage; +use crate::editors::monster_spawn::MonsterSpawnEditorMessage; use crate::editors::tile_grid::TileGridCanvasMessage; use crate::editors::tile_grid::TileGridEditorMessage; diff --git a/gui-widgets/src/components/mod.rs b/gui-widgets/src/components/mod.rs index 4cff5c14..39984914 100644 --- a/gui-widgets/src/components/mod.rs +++ b/gui-widgets/src/components/mod.rs @@ -14,4 +14,4 @@ pub use modal::modal; pub use paragraph_cache::ParagraphCache; pub use popover::popover; pub use table_widget::{RowFlags, TableColumn, TableState, TableWidget}; -pub use text_area::{TextAreaContent, textarea, textarea_style}; +pub use text_area::{TextAreaContent, textarea, textarea_fill, textarea_style}; diff --git a/gui-widgets/src/components/text_area.rs b/gui-widgets/src/components/text_area.rs index f8e02f98..e79e4a6d 100644 --- a/gui-widgets/src/components/text_area.rs +++ b/gui-widgets/src/components/text_area.rs @@ -1,6 +1,6 @@ use iced::advanced::text::Wrapping; use iced::widget::text_editor; -use iced::{Background, Border, Color, Element, color}; +use iced::{Background, Border, Color, Element, Length, color}; /// Owned `text_editor::Content` that can be stored in app state. /// @@ -24,13 +24,40 @@ impl Clone for TextAreaContent { } } -/// Styled multiline text area using the project's leather/medieval theme. +/// Styled multiline text area that grows with its wrapped content. /// -/// Matches the color palette of other input fields in the project. +/// This is best for fields inside a parent scrollable, such as an inspector. +/// Use [`textarea_fill`] when the editor should take its container's remaining +/// vertical space instead. pub fn textarea<'a, Message>( content: &'a text_editor::Content, on_action: impl Fn(text_editor::Action) -> Message + 'a, ) -> Element<'a, Message> +where + Message: 'a, +{ + textarea_with_height(content, on_action, Length::Fit) +} + +/// Styled multiline text area that fills its container's remaining height. +/// +/// Use this for a primary editor pane whose surrounding layout already +/// constrains its height. +pub fn textarea_fill<'a, Message>( + content: &'a text_editor::Content, + on_action: impl Fn(text_editor::Action) -> Message + 'a, +) -> Element<'a, Message> +where + Message: 'a, +{ + textarea_with_height(content, on_action, Length::Fill) +} + +fn textarea_with_height<'a, Message>( + content: &'a text_editor::Content, + on_action: impl Fn(text_editor::Action) -> Message + 'a, + height: Length, +) -> Element<'a, Message> where Message: 'a, { @@ -38,7 +65,7 @@ where .on_action(on_action) .padding([8, 10]) .size(12) - .height(80.0) + .height(height) .wrapping(Wrapping::WordOrGlyph) .style(textarea_style) .into() diff --git a/gui-widgets/src/lib.rs b/gui-widgets/src/lib.rs index 0aed9fab..78961daa 100644 --- a/gui-widgets/src/lib.rs +++ b/gui-widgets/src/lib.rs @@ -12,5 +12,5 @@ pub mod style; pub use components::{ CollapsibleTree, RenderContext, RowFlags, TableColumn, TableState, TableWidget, - TextAreaContent, TreeNode, textarea, textarea_style, + TextAreaContent, TreeNode, textarea, textarea_fill, textarea_style, }; diff --git a/src/commands/registry/mod.rs b/src/commands/registry/mod.rs index 4fe8fcac..66e26c9f 100644 --- a/src/commands/registry/mod.rs +++ b/src/commands/registry/mod.rs @@ -123,21 +123,32 @@ pub fn get_type_fields(key: &str) -> Vec { "effect_visual_id", "targeting_mode", ], - "chest_spawn" => vec![ - "serial", "x", "y", "direction", "item_group", "gold_class", - ], + "chest_spawn" => vec!["serial", "x", "y", "direction", "item_group", "gold_class"], "monster_spawn" => vec![ - "serial", "monster_no", "x", "y", "persistent", "burrow", - "item_group", "drop_attribute", + "serial", + "monster_no", + "x", + "y", + "persistent", + "burrow", + "item_group", + "drop_attribute", ], "map_info_multi" => vec![ - "id", "self_name", "anchor_tile_index", "stride", "row_step", - "start_pos_a_x", "start_pos_a_y", "start_pos_b_x", "start_pos_b_y", - "transition_target", "mon_filename", "npc_spawns", - ], - "tile_grid" => vec![ - "id", "payload_length", "grid_size", "data", + "id", + "self_name", + "anchor_tile_index", + "stride", + "row_step", + "start_pos_a_x", + "start_pos_a_y", + "start_pos_b_x", + "start_pos_b_y", + "transition_target", + "mon_filename", + "npc_spawns", ], + "tile_grid" => vec!["id", "payload_length", "grid_size", "data"], "store" => vec![ "id", "name", From 93347d6215a04e73caa6e7f0a1ee40b8912d9724 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:21:49 +0200 Subject: [PATCH 46/49] Render the sprites with black color preservation --- .../src/editors/sprite_editor/message.rs | 2 + dispel-gui/src/editors/sprite_editor/state.rs | 7 +- .../src/editors/sprite_editor/update.rs | 33 +- dispel-gui/src/editors/sprite_editor/view.rs | 28 +- docs/files/Sprites.spr.md | 266 +++----- src/cli.rs | 5 +- src/commands/database.rs | 37 +- src/commands/sprite.rs | 17 +- src/main.rs | 2 + src/sprite.rs | 590 ++++++++++++++---- 10 files changed, 663 insertions(+), 324 deletions(-) diff --git a/dispel-gui/src/editors/sprite_editor/message.rs b/dispel-gui/src/editors/sprite_editor/message.rs index e91b1bc5..dd6cfda7 100644 --- a/dispel-gui/src/editors/sprite_editor/message.rs +++ b/dispel-gui/src/editors/sprite_editor/message.rs @@ -47,6 +47,8 @@ pub enum SpriteViewerMessage { ZoomOut, ZoomReset, ZoomToFit, + /// Toggle whether raw `0x0000` pixels are displayed as opaque black. + TogglePreserveBlack, // ── Editing ────────────────────────────────────────────────────────────── /// Save the current sprite file to disk. Save, diff --git a/dispel-gui/src/editors/sprite_editor/state.rs b/dispel-gui/src/editors/sprite_editor/state.rs index 78fe0456..1babe7ee 100644 --- a/dispel-gui/src/editors/sprite_editor/state.rs +++ b/dispel-gui/src/editors/sprite_editor/state.rs @@ -50,6 +50,8 @@ pub struct SpriteViewerState { pub selected_frame: usize, pub frames: Vec, pub error: Option, + /// Show raw `0x0000` pixels as opaque black instead of transparent. + pub preserve_black: bool, // Editing pub dirty: bool, pub undo_stack: Vec, @@ -83,6 +85,7 @@ impl Default for SpriteViewerState { selected_frame: 0, frames: Vec::new(), error: None, + preserve_black: false, dirty: false, undo_stack: Vec::new(), redo_stack: Vec::new(), @@ -202,7 +205,7 @@ impl SpriteViewerState { } } - /// Rebuild `self.frames` from `self.sprite_file` by decoding all RGB565 + /// Rebuild `self.frames` from `self.sprite_file` by decoding all 16-bit /// pixel data to RGBA and creating iced image handles. /// /// Call this after any edit to `sprite_file` to keep the display in sync. @@ -222,7 +225,7 @@ impl SpriteViewerState { let w = frame.width.max(0) as u32; let h = frame.height.max(0) as u32; let rgba = if w > 0 && h > 0 { - frame.decode_to_rgba() + frame.decode_to_rgba_with_black(self.preserve_black) } else { Vec::new() }; diff --git a/dispel-gui/src/editors/sprite_editor/update.rs b/dispel-gui/src/editors/sprite_editor/update.rs index d8e86cef..7762c4cf 100644 --- a/dispel-gui/src/editors/sprite_editor/update.rs +++ b/dispel-gui/src/editors/sprite_editor/update.rs @@ -4,7 +4,9 @@ use crate::app::App; use crate::editors::sprite_editor::{ ExportDialogState, ExportFormat, ExportStatus, SpriteViewerMessage, }; -use dispel_core::sprite::{SpriteFrameData, write_sprite_to_path}; +use dispel_core::sprite::{ + SpriteFrameData, SpritePixelFormat, rgba_to_rgb16_bytes, write_sprite_to_path, +}; use iced::Task; /// Real-time milliseconds per animation tick (~60 fps clock). @@ -126,6 +128,10 @@ pub fn handle(message: SpriteViewerMessage, app: &mut App) -> Task { + viewer.preserve_black = !viewer.preserve_black; + viewer.rebuild_preview_frames(); + } // ── Undo / Redo ────────────────────────────────────────────────────── SpriteViewerMessage::Undo => { @@ -147,6 +153,13 @@ pub fn handle(message: SpriteViewerMessage, app: &mut App) -> Task Task Task Task Element<'_, Message> { if let Some(ref dlg) = viewer.export_dialog { modal( base, - view_export_dialog(dlg), + view_export_dialog(viewer, dlg), || sv(SpriteViewerMessage::CloseExportDialog), 0.5, ) @@ -270,6 +272,13 @@ fn view_zoom_controls(viewer: &SpriteViewerState) -> Element<'_, Message> { .size(11) .style(style::subtle_text); + let preserve_black = row![ + checkbox(viewer.preserve_black).on_toggle(|_| sv(SpriteViewerMessage::TogglePreserveBlack)), + text("Keep #000 black").size(11), + ] + .spacing(4) + .align_y(Alignment::Center); + container( row![ zoom_out, @@ -279,6 +288,8 @@ fn view_zoom_controls(viewer: &SpriteViewerState) -> Element<'_, Message> { zoom_fit, Space::new().width(4), zoom_label, + Space::new().width(12), + preserve_black, ] .spacing(2) .padding([2, 8]) @@ -634,7 +645,10 @@ fn view_playback_controls(viewer: &SpriteViewerState) -> Element<'_, Message> { // ── Export dialog ───────────────────────────────────────────────────────────── -fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> { +fn view_export_dialog<'a>( + viewer: &'a SpriteViewerState, + dlg: &'a ExportDialogState, +) -> Element<'a, Message> { let title = text("Export Sprite").size(14).style(style::primary_text); // Format selector @@ -660,6 +674,13 @@ fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> { style::chip }); + let preserve_black = row![ + checkbox(viewer.preserve_black).on_toggle(|_| sv(SpriteViewerMessage::TogglePreserveBlack)), + text("Keep #000 black").size(12), + ] + .spacing(4) + .align_y(Alignment::Center); + // Destination folder let dir_label = text( dlg.export_dir @@ -719,6 +740,7 @@ fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> { horizontal_rule(1), text("Format").size(11).style(style::subtle_text), row![fmt_png, fmt_sheet].spacing(8), + preserve_black, text("Destination").size(11).style(style::subtle_text), row![dir_label, Space::new().width(Fill), choose_dir] .spacing(8) diff --git a/docs/files/Sprites.spr.md b/docs/files/Sprites.spr.md index cf637285..3d0bc8bb 100644 --- a/docs/files/Sprites.spr.md +++ b/docs/files/Sprites.spr.md @@ -1,244 +1,164 @@ # *.spr — Sprite File Format -> **DISPEL®** is a registered trademark. This project is not affiliated with, endorsed by, or sponsored by the trademark -> owner. +> **DISPEL®** is a registered trademark. This project is not affiliated with, +> endorsed by, or sponsored by the trademark owner. -## File Information +## Scope -- **Location**: `CharacterInGame/*.spr`, `NpcInGame/*.spr`, `MonsterInGame/*.spr`, `ExtraInGame/*.spr` -- **Format**: Binary (Little-Endian) -- **Color Format**: RGB565 (2 bytes per pixel) -- **File Extension**: `.spr` +This document describes the `.spr` layout that this project reads and writes. +All integer values use little-endian byte order. ---- +## File Summary -## File Layout - -``` -Offset Size Description ----------- ------------ ------------------------------------------- -0 268 bytes Unknown file header (skip to offset 268) -268 variable Sequence 1 -... variable Sequence 2 -... variable Sequence N -``` - -The file ends with the last byte of the last sequence's pixel data. There is no footer or sequence count in the header. +- **Extension:** `.spr` +- **Data:** counted sequence records +- **Pixel data:** one or two bytes per pixel +- **Two-byte formats:** RGB555 or RGB565 ---- +## File Layout -## Sequence Detection +| Offset | Size | Description | +|---|---:|---| +| `0` | 4 bytes | Sequence-record count (`i32`) | +| `4` | Variable | Sequence records | -Sequences are not stored at fixed offsets. They must be found by scanning the file for a valid sequence header pattern. -The algorithm reads 15 consecutive i32 values (60 bytes) and checks two valid patterns: +The file has no footer. -**Pattern A (no leading stamp):** +## Sequence Record -| Field | Constraint | Meaning | -|-----------------------|----------------|-------------------------------| -| `ints[0]` | `== 0` | No stamp | -| `ints[1]` | `> 0 && < 255` | Frame count (1–254 frames) | -| `ints[2]` | `== 0` | Padding | -| `ints[11]` | `> 0` | First frame width | -| `ints[12]` | `> 0` | First frame height | -| `ints[11] * ints[12]` | `== ints[13]` | Width × height == pixel count | +Each sequence record starts with a 268-byte header. -**Pattern B (with leading stamp 8, 0):** +| Offset | Size | Description | +|---|---:|---| +| `0` | 4 bytes | Sequence identifier (`i32`) | +| `4` | 260 bytes | Name or reserved data | +| `264` | 4 bytes | Group count (`i32`) | -| Field | Constraint | Meaning | -|-----------------------|----------------|-------------------------------| -| `ints[0]` | `== 8` | Stamp byte | -| `ints[1]` | `== 0` | Stamp padding | -| `ints[2]` | `> 0 && < 255` | Frame count (1–254 frames) | -| `ints[3]` | `== 0` | Padding | -| `ints[12]` | `> 0` | First frame width | -| `ints[13]` | `> 0` | First frame height | -| `ints[12] * ints[13]` | `== ints[14]` | Width × height == pixel count | +The group data follows this header. -If neither pattern matches, the scanner advances by 4 bytes (one i32) and retries. +## Group Record ---- +Each group starts with a 12-byte header. -## Sequence Header +| Offset | Size | Description | +|---|---:|---| +| `0` | 4 bytes | Variant (`i32`) | +| `4` | 4 bytes | Frame count (`i32`) | +| `8` | 4 bytes | Reserved data (`i32`) | -Once a valid pattern is found, the sequence header is read: +The frames for this group follow the group header. -| Offset (relative) | Size | Description | -|-------------------|-------|-------------------------------------------------------------| -| `0` | `i32` | Stamp: `8` (Pattern B) or `0` (Pattern A) | -| `4` | `i32` | If stamp was `8`: always `0`. If stamp was `0`: frame count | -| `8` | `i32` | Frame count (Pattern A only) | -| `12` | `i32` | Always `0` (padding) | +The parser uses `variant == 1` for one-byte pixels. Other variants use +two-byte pixels. The parser does not render one-byte pixels because it has no +palette for them. -After the header, frame metadata begins immediately. +## Frame Record ---- +Each frame has a 44-byte header followed by its pixel data. -## Frame Metadata (per frame) +| Offset | Size | Description | +|---|---:|---| +| `0` | 24 bytes | Reserved data | +| `24` | 4 bytes | Origin X (`i32`) | +| `28` | 4 bytes | Origin Y (`i32`) | +| `32` | 4 bytes | Width in pixels (`i32`) | +| `36` | 4 bytes | Height in pixels (`i32`) | +| `40` | 4 bytes | Pixel count (`u32`) | -Each frame in the sequence has a metadata block: +The pixel data begins immediately after the frame header. -| Offset | Size | Description | -|--------|-------|-----------------------------------------------------| -| `0` | `24` | Unknown data (6 × i32, skipped during parsing) | -| `24` | `i32` | `origin_x` — X offset from anchor point | -| `28` | `i32` | `origin_y` — Y offset from anchor point | -| `32` | `i32` | `width` — frame width in pixels | -| `36` | `i32` | `height` — frame height in pixels | -| `40` | `u32` | `pixel_count` — number of RGB565 pixels (NOT bytes) | +Calculate its size as follows: -The pixel data size in bytes is: `pixel_count * 2`. +```text +pixel_data_size = pixel_count × bytes_per_pixel +``` -After the metadata block, pixel data follows immediately. +## Two-Byte Pixel Data ---- +Two-byte pixels use RGB555 or RGB565 packing. -## Frame Pixel Data +| Format | Red | Green | Blue | +|---|---|---|---| +| RGB565 | bits 15–11 | bits 10–5 | bits 4–0 | +| RGB555 | bits 14–10 | bits 9–5 | bits 4–0 | -Each pixel is stored as RGB565 (16-bit, Little-Endian): +Pixels use row-major order. The first pixel is at the top-left corner. -| Bits | Channel | Range | -|-------|---------|---------------| -| 15–11 | Red | 5 bits (0–31) | -| 10–5 | Green | 6 bits (0–63) | -| 4–0 | Blue | 5 bits (0–31) | +The default renderer treats `0x0000` as transparent. Use `--preserve-black` +to export this value as opaque `#000000`. -**Conversion to 8-bit RGB:** +### Format Selection -``` -R = (red_value << 3) // 5→8 bits, range 0–248 -G = (green_value << 2) // 6→8 bits, range 0–252 -B = (blue_value << 3) // 5→8 bits, range 0–248 -``` +The layout has no field that selects RGB555 or RGB565. -Pixel value `0` (all channels zero) is transparent. +The parser uses this rule for an archive with two-byte pixels: -Pixels are stored in row-major order (left-to-right, top-to-bottom). +1. If a non-zero pixel has bit 15 set, use RGB565. +2. Otherwise, use RGB555. +3. If all pixels are zero, use RGB565. ---- +This rule cannot identify an RGB565 archive that never sets bit 15. Add an +explicit format option if such a file needs different handling. -## Bounding Rectangle Calculation +## Frame Position -Frames within a sequence may have different sizes and origins. To render them into a common canvas, a bounding rectangle -is computed: +`origin_x` and `origin_y` define the reference point in a frame. Use these +values to place frames in a common image area. -``` +```text max_left = max(origin_x) max_right = max(width - origin_x) max_up = max(origin_y) max_down = max(height - origin_y) -canvas_width = max_left + max_right (or single frame width) -canvas_height = max_up + max_down (or single frame height) -``` - -Each frame is placed at offset: +canvas_width = max_left + max_right +canvas_height = max_up + max_down -``` x = max_left - origin_x y = max_up - origin_y ``` ---- - -## Reading a File (Step-by-Step) - -1. Open the file, seek to offset 268. -2. Call `seek_next_sequence()` to find the next valid sequence header. If it returns `false`, you've reached the end of - the file. -3. Call `get_sequence_info()` to parse the header and all frame metadata. This advances the reader past all pixel data - to the end of the sequence. -4. To render frames, seek back to `sequence_start_position` before reading pixel data. -5. Seek to `sequence_end_position` to continue scanning for the next sequence. -6. Repeat steps 2–5 until `seek_next_sequence()` returns `false`. - ---- - -## File Purpose - -Stores character sprites, animations, and visual effects for: +For a single frame, use its own width and height. -- NPCs and monsters -- Party members -- Special effects -- Interactive objects +## Read a File ---- +1. Read the sequence-record count. +2. Read each sequence header. +3. Read each group header. +4. Read the frame headers and pixel data for that group. +5. Use `pixel_count` and the byte stride to move to the next frame. -## Sprite Types - -- Character Sprites -- Monster Sprites -- NPC Sprites -- Objects - ---- - -## Origin Points - -Each frame has an origin point (`origin_x`, `origin_y`) that defines: - -- The reference point for positioning -- Rotation center -- Collision detection point - ---- - -## Related Files - -- `Npc.ini` — NPC definitions referencing sprites -- `Monster.ini` — Monster definitions referencing sprites -- `Extra.ini` — Object definitions referencing sprites -- `*.map` files — Map files containing embedded sprites - ---- - -## Extractor - -The file is parsed by the `src/sprite.rs` module. - -### CLI Commands +## Command-Line Use ```bash -# Extract individual frames as separate PNG files +# Export individual frames. cargo run -- sprite "path/to/file.spr" -# Extract animation sequences as horizontal strip PNGs, aligned by origin +# Export animation strips. The exporter aligns frames by their origin values. cargo run -- sprite "path/to/file.spr" --mode animation -# Extract animation sequences without applying origin offsets +# Export animation strips without origin alignment. cargo run -- sprite "path/to/file.spr" --mode animation --no-origin-offset -# Output sprite metadata as JSON (no rendering) +# Keep raw 0x0000 pixels as opaque black in exported PNG files. +cargo run -- sprite "path/to/file.spr" --preserve-black + +# Print metadata as JSON. cargo run -- sprite "path/to/file.spr" --info ``` -### Library API +## Library API ```rust use dispel_core::sprite::{ - get_sprite_info, // Get full metadata as JSON-serializable struct - get_sprite_metadata, // Get frame counts per sequence - get_sequence_pngs_by_index, // Render a specific sequence to PNG buffers + get_sequence_pngs_by_index, + get_sprite_info, + get_sprite_metadata, }; -// Get all sequences and frame counts let frame_counts = get_sprite_metadata(Path::new("file.spr"))?; - -// Get full metadata let info = get_sprite_info(Path::new("file.spr"))?; - -// Render sequence 0 to PNG buffers let pngs = get_sequence_pngs_by_index(Path::new("file.spr"), 0)?; -// => Vec> — one PNG buffer per frame ``` -### Animation Sequences - -Sprites can contain multiple animation sequences: - -- Walking cycles -- Attack animations -- Special ability effects -- Idle animations -- Death sequences +`get_sequence_pngs_by_index` returns one PNG buffer for each frame. diff --git a/src/cli.rs b/src/cli.rs index 3aad060c..87c69190 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,7 +78,7 @@ pub enum Commands { /// Sprite/Animation extraction #[command( about = "Extract frames or sequences from SPR files", - long_about = "Parses .SPR (Sprite) \n\nUsage Examples:\n dispel-extractor sprite character.spr\n dispel-extractor sprite animation_effect.spr --mode animation\n dispel-extractor sprite character.spr --info" + long_about = "Parses .SPR (Sprite) \n\nUsage Examples:\n dispel-extractor sprite character.spr\n dispel-extractor sprite animation_effect.spr --mode animation\n dispel-extractor sprite character.spr --preserve-black\n dispel-extractor sprite character.spr --info" )] Sprite { /// Path to the source .SPR file @@ -89,6 +89,9 @@ pub enum Commands { /// Render animation frames at their raw top-left position instead of aligning their origins #[arg(long)] no_origin_offset: bool, + /// Keep raw 0x0000 pixels as opaque #000000 instead of making them transparent + #[arg(long)] + preserve_black: bool, /// Output sprite metadata as JSON (no rendering) #[arg(long)] info: bool, diff --git a/src/commands/database.rs b/src/commands/database.rs index 869c0922..6fc10e04 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -743,7 +743,6 @@ fn import_event_scripts(main_path: &Path, conn: &mut Connection) -> Result<(), B fn import_sprite_files(main_path: &Path, conn: &mut Connection) -> Result<(), Box> { use image::ImageEncoder; - use std::io::{Seek, SeekFrom}; // Ensure the schema exists (idempotent — uses CREATE TABLE IF NOT EXISTS) initialize_database(conn)?; @@ -809,32 +808,18 @@ fn import_sprite_files(main_path: &Path, conn: &mut Connection) -> Result<(), Bo match std::fs::File::open(&path) { Ok(file) => { - let file_len = match file.metadata() { - Ok(m) => m.len(), - Err(_) => { + let mut reader = std::io::BufReader::new(file); + let infos = match dispel_core::sprite::get_sprite_sequence_infos(&path) { + Ok(infos) => infos, + Err(e) => { + eprintln!("WARNING: sprite parse failed for {}: {e}", path.display()); errors += 1; return Ok(()); } }; - let mut reader = std::io::BufReader::new(file); - - // Skip 268-byte header - if reader.seek(SeekFrom::Start(268)).is_err() { - errors += 1; - return Ok(()); - } - let mut seq_idx = 0; - loop { - let pos = reader.stream_position().unwrap_or(file_len); - match dispel_core::sprite::seek_next_sequence(&mut reader, pos, file_len) { - Ok(true) => {} - _ => break, - } - let info = match dispel_core::sprite::get_sequence_info(&mut reader) { - Ok(i) => i, - Err(_) => break, - }; + for (seq_idx, info) in infos.iter().enumerate() { + let seq_idx = seq_idx as i32; let frame_count = info.frame_infos.len() as i32; let mut has_frame_data = false; @@ -911,14 +896,6 @@ fn import_sprite_files(main_path: &Path, conn: &mut Connection) -> Result<(), Bo errors += 1; } } - - if reader - .seek(SeekFrom::Start(info.sequence_end_position)) - .is_err() - { - break; - } - seq_idx += 1; } count += 1; diff --git a/src/commands/sprite.rs b/src/commands/sprite.rs index a6413a62..c8fe25e6 100644 --- a/src/commands/sprite.rs +++ b/src/commands/sprite.rs @@ -9,6 +9,7 @@ pub struct SpriteCommand { pub input: String, pub mode: CliSpriteMode, pub no_origin_offset: bool, + pub preserve_black: bool, pub info: bool, } @@ -35,12 +36,20 @@ impl Command for SpriteCommand { .file_name() .and_then(|n| n.to_str()) .unwrap_or("sprite"); - sprite::extract(Path::new(&self.input), prefix.to_string()) - .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; + sprite::extract_with_black( + Path::new(&self.input), + prefix.to_string(), + self.preserve_black, + ) + .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; } SpriteMode::Animation => { - sprite::animation(Path::new(&self.input), !self.no_origin_offset) - .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; + sprite::animation_with_black( + Path::new(&self.input), + !self.no_origin_offset, + self.preserve_black, + ) + .map_err(|e| format!("ERROR: could not export sprite: {e}"))?; } } Ok(()) diff --git a/src/main.rs b/src/main.rs index 678a2c60..5c83e8c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,13 @@ fn main() { input, mode, no_origin_offset, + preserve_black, info, }) => SpriteCommand { input: input.clone(), mode: *mode, no_origin_offset: *no_origin_offset, + preserve_black: *preserve_black, info: *info, } .execute(), diff --git a/src/sprite.rs b/src/sprite.rs index 1e7c4fac..d1f18260 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -15,18 +15,17 @@ use std::{fs::File, path::Path}; // Full documentation: docs/files/Map/Sprites.spr.md // // Quick reference: -// - 268-byte unknown header, then variable-length sequences -// - Sequences found by scanning for valid header patterns (15×i32) -// - Each sequence: header → frame metadata blocks → RGB565 pixel data -// - RGB565: 5R/6G/5B, 0x0000=transparent, little-endian +// - leading i32 sequence count, followed by counted, variable-length groups +// - each sequence has a 268-byte header and zero or more frame groups +// - each group: 12-byte header → frame metadata blocks → pixel data +// - 16-bit RGB555 or RGB565, 0x0000=transparent, little-endian // - Frames have origin_x/origin_y for alignment in a bounding rect // // Reading flow: -// 1. seek(268) -// 2. seek_next_sequence() → find header or EOF -// 3. get_sequence_info() → parse header + frame metadata -// 4. seek(sequence_start_position) → render frames -// 5. seek(sequence_end_position) → continue to next sequence +// 1. read sequence_count at offset 0 +// 2. read every 268-byte sequence header and its groups sequentially +// 3. parse each non-empty group into a SequenceInfo +// 4. use each frame's recorded pixel-data offset to render it // // =========================================================================== @@ -49,9 +48,17 @@ pub struct ImageInfo { pub width: i32, /// Frame height in pixels. pub height: i32, - /// Size of the pixel data in bytes (width × height × 2). + /// Size of the pixel data in bytes (width × height × `bytes_per_pixel`). pub size_bytes: i64, - /// File offset where this frame's RGB565 pixel data begins. + /// Number of bytes occupied by one pixel in the source stream. + /// + /// Normal groups use two-byte RGB555 or RGB565 pixels. The original loader + /// also recognizes one-byte groups; they are parsed and skipped correctly, + /// but cannot be rendered without their palette. + pub bytes_per_pixel: u8, + /// Channel packing for a two-byte pixel. + pub pixel_format: SpritePixelFormat, + /// File offset where this frame's pixel data begins. pub image_start_position: u64, } @@ -73,7 +80,7 @@ pub struct SequenceInfo { pub frame_infos: Vec, } -/// An RGB color decoded from a 16-bit RGB565 pixel value. +/// An RGB color decoded from a 16-bit sprite pixel value. #[derive(Clone, Copy, Debug)] pub struct Color { /// Red component (0-255). @@ -84,12 +91,20 @@ pub struct Color { pub b: u8, } +/// Channel layout of a 16-bit sprite pixel. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SpritePixelFormat { + Rgb555, + Rgb565, +} + #[derive(Debug, Clone, serde::Serialize)] pub struct FrameInfoJson { pub origin_x: i32, pub origin_y: i32, pub width: i32, pub height: i32, + pub pixel_format: SpritePixelFormat, } #[derive(Debug, Clone, serde::Serialize)] @@ -135,7 +150,26 @@ pub fn rgb16_565_produce_color(pixel: u16) -> Color { } } -fn get_image_info(reader: &mut BufReader) -> Result { +/// Decode a 16-bit RGB555 pixel into an 8-bit RGB [`Color`]. +pub fn rgb16_555_produce_color(pixel: u16) -> Color { + Color { + r: (((pixel & 0x7C00) >> 10) << 3) as u8, + g: (((pixel & 0x03E0) >> 5) << 3) as u8, + b: ((pixel & 0x001F) << 3) as u8, + } +} + +pub fn rgb16_produce_color(pixel: u16, pixel_format: SpritePixelFormat) -> Color { + match pixel_format { + SpritePixelFormat::Rgb555 => rgb16_555_produce_color(pixel), + SpritePixelFormat::Rgb565 => rgb16_565_produce_color(pixel), + } +} + +fn get_image_info_with_pixel_stride( + reader: &mut BufReader, + bytes_per_pixel: u8, +) -> Result { reader.seek(SeekFrom::Current(6 * 4))?; let origin_x = reader.read_i32::()?; @@ -143,8 +177,12 @@ fn get_image_info(reader: &mut BufReader) -> Result()?; let height = reader.read_i32::()?; - let size_bytes = reader.read_u32::()?; - let size_bytes = (size_bytes as i64) * 2; + let pixel_count = reader.read_u32::()?; + let size_bytes = i64::from(pixel_count) + .checked_mul(i64::from(bytes_per_pixel)) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "frame is too large") + })?; let image_start_position = reader.stream_position()?; @@ -165,10 +203,16 @@ fn get_image_info(reader: &mut BufReader) -> Result(reader: &mut BufReader) -> Result { + get_image_info_with_pixel_stride(reader, 2) +} + /// Parses a single sequence header and all its frame metadata. /// /// The reader must be positioned at the start of a valid sequence header @@ -207,6 +251,135 @@ pub fn get_sequence_info(reader: &mut BufReader) -> Result Result { + usize::try_from(value).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("negative {field}: {value}"), + ) + }) +} + +fn ensure_sprite_archive(reader: &mut BufReader) -> Result<()> { + reader.seek(SeekFrom::Start(0))?; + let mut magic = [0_u8; 2]; + reader.read_exact(&mut magic)?; + if magic == *b"BM" { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "BMP data uses a .spr extension; it is not a DISPEL sprite archive", + )); + } + reader.seek(SeekFrom::Start(0))?; + Ok(()) +} + +/// Read the sprite archive in the order used by the original game loader. +/// +/// The public API calls each non-empty group a sequence. This preserves the +/// existing extractor/manifest convention while following the archive's real +/// layout: a leading sequence count, then 268-byte sequence headers, then +/// variable-length group records and their frame data. +fn read_sprite_sequence_infos( + reader: &mut BufReader, +) -> Result> { + ensure_sprite_archive(reader)?; + + let sequence_count = checked_count(reader.read_i32::()?, "sequence count")?; + let mut sequences = Vec::new(); + + for _ in 0..sequence_count { + // Per-sequence header: id/unknown (4), name (260), group count (4). + reader.seek(SeekFrom::Current(4 + 260))?; + let group_count = checked_count(reader.read_i32::()?, "group count")?; + + for _ in 0..group_count { + // The group header is { variant, frame_count, unknown }. Variant 1 + // has one byte per pixel; the normal (and all shipped) variant has + // RGB565 pixels occupying two bytes each. + let variant = reader.read_i32::()?; + let frame_count = checked_count(reader.read_i32::()?, "frame count")?; + _ = reader.read_i32::()?; + let bytes_per_pixel = if variant == 1 { 1 } else { 2 }; + + let sequence_start_position = reader.stream_position()?; + let mut frame_infos = Vec::with_capacity(frame_count); + for _ in 0..frame_count { + let image_info = get_image_info_with_pixel_stride(reader, bytes_per_pixel)?; + reader.seek(SeekFrom::Current(image_info.size_bytes))?; + frame_infos.push(image_info); + } + let sequence_end_position = reader.stream_position()?; + + // Empty groups are direction/animation slots, not extractable + // image sequences. The legacy scanner omitted them as well. + if !frame_infos.is_empty() { + sequences.push(SequenceInfo { + sequence_start_position, + sequence_end_position, + frame_count: frame_count as i32, + frame_infos, + }); + } + } + } + + assign_16bit_pixel_format(reader, &mut sequences)?; + + Ok(sequences) +} + +/// The archive has no explicit RGB555/RGB565 tag. The original blitter selects +/// its masks from the display configuration; assets themselves reveal RGB555 +/// by never using bit 15. `Menu/MBack.spr` is the verified shipped example. +fn assign_16bit_pixel_format( + reader: &mut BufReader, + sequences: &mut [SequenceInfo], +) -> Result<()> { + let mut saw_nontransparent_pixel = false; + let mut uses_bit_15 = false; + + for sequence in sequences.iter() { + for frame in &sequence.frame_infos { + if frame.bytes_per_pixel != 2 { + continue; + } + reader.seek(SeekFrom::Start(frame.image_start_position))?; + let word_count = u64::try_from(frame.size_bytes / 2).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid frame size") + })?; + for _ in 0..word_count { + let pixel = reader.read_u16::()?; + if pixel != 0 { + saw_nontransparent_pixel = true; + uses_bit_15 |= pixel & 0x8000 != 0; + } + } + } + } + + let pixel_format = if saw_nontransparent_pixel && !uses_bit_15 { + SpritePixelFormat::Rgb555 + } else { + SpritePixelFormat::Rgb565 + }; + for sequence in sequences { + for frame in &mut sequence.frame_infos { + if frame.bytes_per_pixel == 2 { + frame.pixel_format = pixel_format; + } + } + } + Ok(()) +} + +/// Return every non-empty frame group in a standalone `.spr` archive. +pub fn get_sprite_sequence_infos(file_path: &Path) -> Result> { + let file = File::open(file_path)?; + let mut reader = BufReader::new(file); + read_sprite_sequence_infos(&mut reader) +} + /// Scans forward from `start_pos` to find the next valid sequence header. /// /// Reads 15 consecutive i32 values (60 bytes) and checks for known sequence @@ -332,18 +505,42 @@ pub fn render_frame_to_rgba( offset_x: u32, offset_y: u32, ) -> Result { + render_frame_to_rgba_with_black(reader, frame, rect_w, rect_h, offset_x, offset_y, false) +} + +/// Render a frame as RGBA, optionally retaining raw `0x0000` pixels as +/// opaque black instead of treating them as transparent. +pub fn render_frame_to_rgba_with_black( + reader: &mut BufReader, + frame: &ImageInfo, + rect_w: u32, + rect_h: u32, + offset_x: u32, + offset_y: u32, + preserve_black: bool, +) -> Result { + if frame.bytes_per_pixel != 2 { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "cannot render one-byte sprite pixels without a palette", + )); + } + let mut imgbuf = RgbaImage::new(rect_w, rect_h); let frame_width = frame.width.unsigned_abs(); reader.seek(SeekFrom::Start(frame.image_start_position))?; for pixel_idx in 0..(frame.width.unsigned_abs() * frame.height.unsigned_abs()) as usize { let pixel = reader.read_u16::()?; + let x = (pixel_idx as u32 % frame_width) + offset_x; + let y = (pixel_idx as u32 / frame_width) + offset_y; if pixel == 0 { + if preserve_black { + imgbuf.put_pixel(x, y, image::Rgba([0, 0, 0, 255])); + } continue; } - let color = rgb16_565_produce_color(pixel); - let x = (pixel_idx as u32 % frame_width) + offset_x; - let y = (pixel_idx as u32 / frame_width) + offset_y; + let color = rgb16_produce_color(pixel, frame.pixel_format); imgbuf.put_pixel(x, y, image::Rgba([color.r, color.g, color.b, 255])); } Ok(imgbuf) @@ -358,27 +555,11 @@ where F: FnMut(&mut BufReader, &SequenceInfo, usize) -> Result<()>, { let file = File::open(file_path)?; - let file_len = file.metadata()?.len(); let mut reader = BufReader::new(file); + let infos = read_sprite_sequence_infos(&mut reader)?; - reader.seek(SeekFrom::Start(268))?; - - let mut seq_index = 0; - loop { - let pos = reader.stream_position()?; - if pos >= file_len { - break; - } - - let valid = seek_next_sequence(&mut reader, pos, file_len)?; - if !valid { - break; - } - - let info = get_sequence_info(&mut reader)?; + for (seq_index, info) in infos.iter().enumerate() { f(&mut reader, &info, seq_index)?; - reader.seek(SeekFrom::Start(info.sequence_end_position))?; - seq_index += 1; } Ok(()) @@ -393,12 +574,23 @@ where /// When `apply_origin_offsets` is true, each frame is placed in the shared /// sequence bounds so its origin remains fixed across the animation. pub fn animation(file_path: &Path, apply_origin_offsets: bool) -> Result<()> { + animation_with_black(file_path, apply_origin_offsets, false) +} + +/// Export animation atlases, optionally retaining raw `0x0000` pixels as +/// opaque black. +pub fn animation_with_black( + file_path: &Path, + apply_origin_offsets: bool, + preserve_black: bool, +) -> Result<()> { for_each_sequence(file_path, |reader, info, seq_idx| { - save_sequence_anim( + save_sequence_anim_with_black( reader, &info.frame_infos, seq_idx as i32, apply_origin_offsets, + preserve_black, ) })?; println!("Finished"); @@ -406,8 +598,24 @@ pub fn animation(file_path: &Path, apply_origin_offsets: bool) -> Result<()> { } pub fn extract(file_path: &Path, out_file_prefix: String) -> Result<()> { + extract_with_black(file_path, out_file_prefix, false) +} + +/// Export individual frames, optionally retaining raw `0x0000` pixels as +/// opaque black. +pub fn extract_with_black( + file_path: &Path, + out_file_prefix: String, + preserve_black: bool, +) -> Result<()> { for_each_sequence(file_path, |reader, info, seq_idx| { - save_sequence(reader, &info.frame_infos, seq_idx as i32, &out_file_prefix) + save_sequence_with_black( + reader, + &info.frame_infos, + seq_idx as i32, + &out_file_prefix, + preserve_black, + ) })?; println!("Finished"); Ok(()) @@ -418,6 +626,24 @@ pub fn save_sequence_anim( frames: &[ImageInfo], sequence_counter: i32, apply_origin_offsets: bool, +) -> Result<()> { + save_sequence_anim_with_black( + reader, + frames, + sequence_counter, + apply_origin_offsets, + false, + ) +} + +/// Save one animation atlas, optionally retaining raw `0x0000` pixels as +/// opaque black. +pub fn save_sequence_anim_with_black( + reader: &mut BufReader, + frames: &[ImageInfo], + sequence_counter: i32, + apply_origin_offsets: bool, + preserve_black: bool, ) -> Result<()> { println!("Frames: {:?}, Sequence: {sequence_counter}", frames.len()); @@ -435,6 +661,7 @@ pub fn save_sequence_anim( rect_w, rect_h, apply_origin_offsets, + preserve_black, )?; imgbuf @@ -456,6 +683,7 @@ fn render_sequence_animation( rect_w: u32, rect_h: u32, apply_origin_offsets: bool, + preserve_black: bool, ) -> Result { let atlas_w = rect_w * (frames.len() as u32); let mut imgbuf = RgbaImage::new(atlas_w, rect_h); @@ -466,7 +694,15 @@ fn render_sequence_animation( } else { (0, 0) }; - let frame_rgba = render_frame_to_rgba(reader, frame, rect_w, rect_h, offset_x, offset_y)?; + let frame_rgba = render_frame_to_rgba_with_black( + reader, + frame, + rect_w, + rect_h, + offset_x, + offset_y, + preserve_black, + )?; for (px, py, pixel) in frame_rgba.enumerate_pixels() { imgbuf.put_pixel(px + (i as u32 * rect_w), py, *pixel); @@ -481,6 +717,18 @@ pub fn save_sequence( frames: &[ImageInfo], sequence_counter: i32, out_file_prefix: &str, +) -> Result<()> { + save_sequence_with_black(reader, frames, sequence_counter, out_file_prefix, false) +} + +/// Save individual frame PNGs, optionally retaining raw `0x0000` pixels as +/// opaque black. +pub fn save_sequence_with_black( + reader: &mut BufReader, + frames: &[ImageInfo], + sequence_counter: i32, + out_file_prefix: &str, + preserve_black: bool, ) -> Result<()> { println!("Frames: {:?}, Sequence: {sequence_counter}", frames.len()); @@ -493,7 +741,15 @@ pub fn save_sequence( for (i, frame) in frames.iter().enumerate() { let (offset_x, offset_y) = compute_frame_offset(frames, i, rect_x, rect_y); - let frame_rgba = render_frame_to_rgba(reader, frame, rect_w, rect_h, offset_x, offset_y)?; + let frame_rgba = render_frame_to_rgba_with_black( + reader, + frame, + rect_w, + rect_h, + offset_x, + offset_y, + preserve_black, + )?; let outfile = format!("./{}_{:?}-{:?}.png", out_file_prefix, sequence_counter, i); frame_rgba.save(outfile).unwrap(); @@ -549,29 +805,10 @@ pub fn get_sprite_metadata(file_path: &Path) -> Result<(usize, Vec)> { pub fn get_sequence_pngs_by_index(file_path: &Path, sequence_idx: usize) -> Result>> { let file = File::open(file_path)?; - let file_len = file.metadata()?.len(); let mut reader = BufReader::new(file); - - reader.seek(SeekFrom::Start(268))?; - - let mut seq_counter = 0; - loop { - let pos = reader.stream_position()?; - if pos >= file_len { - break; - } - - let valid = seek_next_sequence(&mut reader, pos, file_len)?; - if !valid { - break; - } - - let info = get_sequence_info(&mut reader)?; - if seq_counter == sequence_idx { - reader.seek(SeekFrom::Start(info.sequence_start_position))?; - return get_sequence_frames_as_pngs(&mut reader, &info); - } - seq_counter += 1; + let infos = read_sprite_sequence_infos(&mut reader)?; + if let Some(info) = infos.get(sequence_idx) { + return get_sequence_frames_as_pngs(&mut reader, info); } Err(std::io::Error::new( @@ -584,25 +821,11 @@ pub fn get_sprite_info(file_path: &Path) -> Result { let file = File::open(file_path)?; let file_len = file.metadata()?.len(); let mut reader = BufReader::new(file); + let infos = read_sprite_sequence_infos(&mut reader)?; - reader.seek(SeekFrom::Start(268))?; - - let mut sequences = Vec::new(); + let mut sequences = Vec::with_capacity(infos.len()); let mut total_frames = 0; - let mut seq_index = 0; - - loop { - let pos = reader.stream_position()?; - if pos >= file_len { - break; - } - - let valid = seek_next_sequence(&mut reader, pos, file_len)?; - if !valid { - break; - } - - let info = get_sequence_info(&mut reader)?; + for (seq_index, info) in infos.iter().enumerate() { let frame_count = info.frame_count as usize; total_frames += frame_count; @@ -614,6 +837,7 @@ pub fn get_sprite_info(file_path: &Path) -> Result { origin_y: f.origin_y, width: f.width, height: f.height, + pixel_format: f.pixel_format, }) .collect(); @@ -622,8 +846,6 @@ pub fn get_sprite_info(file_path: &Path) -> Result { frame_count, frames, }); - - seq_index += 1; } Ok(SpriteInfoJson { @@ -674,7 +896,9 @@ pub struct SpriteFrameData { pub width: i32, /// Frame height in pixels. pub height: i32, - /// Raw RGB565 pixel data, width×height×2 bytes, little-endian. + /// Channel packing used by `raw_pixels`. + pub pixel_format: SpritePixelFormat, + /// Raw RGB555 or RGB565 pixel data, width×height×2 bytes, little-endian. /// Stored in original form so unmodified frames round-trip bit-exact. pub raw_pixels: Vec, } @@ -684,8 +908,14 @@ impl SpriteFrameData { (self.width * self.height) as usize } - /// Decode the raw RGB565 pixels into RGBA bytes (width×height×4). + /// Decode the raw 16-bit pixels into RGBA bytes (width×height×4). pub fn decode_to_rgba(&self) -> Vec { + self.decode_to_rgba_with_black(false) + } + + /// Decode raw 16-bit pixels into RGBA bytes, optionally retaining raw + /// `0x0000` pixels as opaque black. + pub fn decode_to_rgba_with_black(&self, preserve_black: bool) -> Vec { let count = self.pixel_count(); let mut rgba = vec![0u8; count * 4]; for i in 0..count { @@ -694,11 +924,14 @@ impl SpriteFrameData { break; } let pixel = u16::from_le_bytes([self.raw_pixels[base], self.raw_pixels[base + 1]]); + let rbase = i * 4; if pixel == 0 { - continue; // transparent, keep RGBA = [0,0,0,0] + if preserve_black { + rgba[rbase + 3] = 255; + } + continue; } - let color = rgb16_565_produce_color(pixel); - let rbase = i * 4; + let color = rgb16_produce_color(pixel, self.pixel_format); rgba[rbase] = color.r; rgba[rbase + 1] = color.g; rgba[rbase + 2] = color.b; @@ -707,21 +940,23 @@ impl SpriteFrameData { rgba } - /// Encode RGBA bytes (width×height×4) into raw RGB565, replacing pixel data. + /// Encode RGBA bytes (width×height×4) into this frame's native 16-bit + /// packing, replacing pixel data. pub fn encode_from_rgba(&mut self, rgba: &[u8]) { let count = self.pixel_count(); let mut raw = vec![0u8; count * 2]; for i in 0..count.min(rgba.len() / 4) { let rbase = i * 4; - let rgb565 = rgba_to_rgb565_bytes( + let encoded = rgba_to_rgb16_bytes( rgba[rbase], rgba[rbase + 1], rgba[rbase + 2], rgba[rbase + 3], + self.pixel_format, ); let base = i * 2; - raw[base] = rgb565[0]; - raw[base + 1] = rgb565[1]; + raw[base] = encoded[0]; + raw[base + 1] = encoded[1]; } self.raw_pixels = raw; } @@ -739,11 +974,85 @@ pub fn read_sprite_file(path: &Path) -> Result { parse_sprite_bytes(&bytes) } +/// Parse a `.spr` archive for the editor-facing flattened group view. +/// +/// `SpriteSequence` predates the discovered outer sequence/group structure; +/// it therefore continues to expose each non-empty group as a sequence. +pub fn parse_sprite_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "sprite archive is smaller than its sequence count", + )); + } + + let mut reader = BufReader::new(Cursor::new(bytes)); + let infos = read_sprite_sequence_infos(&mut reader)?; + // SpriteFile is the editor's flattened group view. Its header describes + // the normalized one-record archive emitted by write_sprite_to_path. + let mut header = [0_u8; 268]; + header[..4].copy_from_slice(&1_i32.to_le_bytes()); + + let mut sequences = Vec::with_capacity(infos.len()); + for info in infos { + let mut frames = Vec::with_capacity(info.frame_infos.len()); + for frame in info.frame_infos { + if frame.bytes_per_pixel != 2 { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "editor support for one-byte sprite groups requires a palette", + )); + } + let metadata_start = usize::try_from(frame.image_start_position) + .ok() + .and_then(|offset| offset.checked_sub(44)) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid frame offset") + })?; + let pixel_start = usize::try_from(frame.image_start_position).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid frame offset") + })?; + let pixel_end = pixel_start + .checked_add(usize::try_from(frame.size_bytes).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid frame size") + })?) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "frame is too large") + })?; + if pixel_end > bytes.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "truncated frame data", + )); + } + + let mut unknown = [0_u8; 24]; + unknown.copy_from_slice(&bytes[metadata_start..metadata_start + 24]); + frames.push(SpriteFrameData { + unknown, + origin_x: frame.origin_x, + origin_y: frame.origin_y, + width: frame.width, + height: frame.height, + pixel_format: frame.pixel_format, + raw_pixels: bytes[pixel_start..pixel_end].to_vec(), + }); + } + sequences.push(SpriteSequence { + has_stamp: false, + frames, + }); + } + + Ok(SpriteFile { header, sequences }) +} + /// Parse `.spr` bytes into the in-memory [`SpriteFile`] representation. /// /// Shared by `read_sprite_file` (filesystem) and callers that hold sprite /// data in memory (e.g. database blobs). See `read_sprite_file` for details. -pub fn parse_sprite_bytes(bytes: &[u8]) -> Result { +#[allow(dead_code)] +fn parse_sprite_bytes_legacy(bytes: &[u8]) -> Result { let file_len = bytes.len(); if file_len < 268 { return Err(std::io::Error::new( @@ -821,6 +1130,7 @@ pub fn parse_sprite_bytes(bytes: &[u8]) -> Result { origin_y, width, height, + pixel_format: SpritePixelFormat::Rgb565, raw_pixels, }); } @@ -872,26 +1182,24 @@ fn is_valid_sequence_header(ints: &[i32; 15]) -> bool { /// Write the in-memory [`SpriteFile`] back to a .spr binary file. /// -/// The 268-byte header and all per-frame 24-byte unknown data are preserved -/// verbatim. Pixel data is re-encoded from raw RGB565. +/// The flattened editor view is emitted as a normalized counted archive. +/// Per-frame metadata and unmodified raw pixels are preserved, but outer +/// sequence headers, empty groups, and group partitioning are not. pub fn write_sprite_to_path(path: &Path, sprite: &SpriteFile) -> Result<()> { let mut buf = Vec::with_capacity(4096); - buf.extend_from_slice(&sprite.header); + // The editor exposes groups as flat sequences. Serialize that view as one + // valid outer sequence record rather than emitting the obsolete heuristic + // format. The 4-byte id and 260-byte name remain zeroed. + buf.extend_from_slice(&1_i32.to_le_bytes()); + buf.extend_from_slice(&[0_u8; 264]); + buf.extend_from_slice(&(sprite.sequences.len() as i32).to_le_bytes()); for seq in &sprite.sequences { let frame_count = seq.frames.len() as i32; - if seq.has_stamp { - // Pattern B: stamp=8, stamp_padding=0, frame_count, padding=0 - buf.extend_from_slice(&8i32.to_le_bytes()); - buf.extend_from_slice(&0i32.to_le_bytes()); - buf.extend_from_slice(&frame_count.to_le_bytes()); - buf.extend_from_slice(&0i32.to_le_bytes()); - } else { - // Pattern A: stamp=0, frame_count, padding=0 - buf.extend_from_slice(&0i32.to_le_bytes()); - buf.extend_from_slice(&frame_count.to_le_bytes()); - buf.extend_from_slice(&0i32.to_le_bytes()); - } + // Group header: normal 16-bit variant, frame count, reserved word. + buf.extend_from_slice(&0_i32.to_le_bytes()); + buf.extend_from_slice(&frame_count.to_le_bytes()); + buf.extend_from_slice(&0_i32.to_le_bytes()); for frame in &seq.frames { buf.extend_from_slice(&frame.unknown); @@ -922,6 +1230,28 @@ pub fn rgba_to_rgb565_bytes(r: u8, g: u8, b: u8, a: u8) -> [u8; 2] { pixel.to_le_bytes() } +/// Encode a single RGBA pixel as two RGB555 bytes (little-endian). +/// +/// Pixels with alpha < 128 are encoded as `0x0000` (transparent). +pub fn rgba_to_rgb555_bytes(r: u8, g: u8, b: u8, a: u8) -> [u8; 2] { + if a < 128 { + return [0, 0]; + } + let r5 = (r as u16 >> 3) & 0x1F; + let g5 = (g as u16 >> 3) & 0x1F; + let b5 = (b as u16 >> 3) & 0x1F; + let pixel = (r5 << 10) | (g5 << 5) | b5; + pixel.to_le_bytes() +} + +/// Encode an RGBA pixel using the requested 16-bit sprite packing. +pub fn rgba_to_rgb16_bytes(r: u8, g: u8, b: u8, a: u8, pixel_format: SpritePixelFormat) -> [u8; 2] { + match pixel_format { + SpritePixelFormat::Rgb555 => rgba_to_rgb555_bytes(r, g, b, a), + SpritePixelFormat::Rgb565 => rgba_to_rgb565_bytes(r, g, b, a), + } +} + // =========================================================================== // Tests // =========================================================================== @@ -953,6 +1283,53 @@ mod tests { assert_eq!(pixel, 0x001F); } + #[test] + fn rgba_to_rgb555_encodes_primary_channels() { + assert_eq!( + u16::from_le_bytes(rgba_to_rgb555_bytes(255, 0, 0, 255)), + 0x7C00 + ); + assert_eq!( + u16::from_le_bytes(rgba_to_rgb555_bytes(0, 255, 0, 255)), + 0x03E0 + ); + assert_eq!( + u16::from_le_bytes(rgba_to_rgb555_bytes(0, 0, 255, 255)), + 0x001F + ); + } + + #[test] + fn rgb555_decodes_five_bit_green() { + let color = rgb16_produce_color(0x03E0, SpritePixelFormat::Rgb555); + assert_eq!((color.r, color.g, color.b), (0, 248, 0)); + } + + #[test] + fn raw_black_can_be_retained_or_transparent() { + let frame = ImageInfo { + origin_x: 0, + origin_y: 0, + width: 1, + height: 1, + size_bytes: 2, + bytes_per_pixel: 2, + pixel_format: SpritePixelFormat::Rgb565, + image_start_position: 0, + }; + + let mut transparent_reader = BufReader::new(Cursor::new(vec![0, 0])); + let transparent = + render_frame_to_rgba_with_black(&mut transparent_reader, &frame, 1, 1, 0, 0, false) + .expect("render transparent black"); + assert_eq!(transparent.get_pixel(0, 0).0, [0, 0, 0, 0]); + + let mut opaque_reader = BufReader::new(Cursor::new(vec![0, 0])); + let opaque = render_frame_to_rgba_with_black(&mut opaque_reader, &frame, 1, 1, 0, 0, true) + .expect("render opaque black"); + assert_eq!(opaque.get_pixel(0, 0).0, [0, 0, 0, 255]); + } + #[test] fn rgba_to_rgb565_transparent_is_zero() { let bytes = rgba_to_rgb565_bytes(255, 0, 0, 0); @@ -987,6 +1364,7 @@ mod tests { origin_y: 0, width: 4, height: 4, + pixel_format: SpritePixelFormat::Rgb565, raw_pixels: vec![0u8; 4 * 4 * 2], }; frame.encode_from_rgba(&pixels); From ae7c7144f2e7be364abe7c393f40b62a05a8359b Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:39:11 +0200 Subject: [PATCH 47/49] Export no combat zone --- docs/files/Map/Map.map.md | 14 ++++++-- docs/files/Map/MapModule.md | 2 +- src/map/mod.rs | 48 ++++++++++++++++++++++---- src/map/types.rs | 46 ++++++++++++++++++++++++ src/queries/create_table_map_tiles.sql | 2 ++ src/queries/insert_map_tile.sql | 4 +-- 6 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index 8d519c26..2c1b8268 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -180,9 +180,17 @@ event_id: i16 -- low half of a packed u32; low 14 bits hold flags: i16 -- high half: parameter/flag bits ``` -In the packed u32, bit 22 acts as a "tile marked / entity occupied" flag (see -`EventBlock::is_tile_marked` in `src/map/mod.rs`); the remaining high bits are -unmapped parameters. +In the packed u32, bit 22 sets `EventBlock::is_tile_marked`. + +`EventBlock::suppresses_btl_pass` returns true only when this expression is +true: + +```text +(word & 0x0FC00000) == 0x00400000 +``` + +`EventBlock::is_no_combat_zone` returns the same derived value for callers +that use this label. The remaining high bits have no documented meaning. ### Tile & Access Block diff --git a/docs/files/Map/MapModule.md b/docs/files/Map/MapModule.md index 3317daaa..ff0a4616 100644 --- a/docs/files/Map/MapModule.md +++ b/docs/files/Map/MapModule.md @@ -390,7 +390,7 @@ This ensures proper isometric depth ordering (painter's algorithm). | Table | Columns | Description | |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------| | `map_metadata` | `map_id`, `tiled_width`, `tiled_height`, `pixel_width`, `pixel_height`, `non_occluded_x`, `non_occluded_y`, `occluded_width`, `occluded_height` | Map dimensions and offsets | -| `map_tiles` | `map_id`, `x`, `y`, `gtl_tile_id`, `btl_tile_id`, `collision`, `event_id` | Per-tile ground/roof/collision/event data | +| `map_tiles` | `map_id`, `x`, `y`, `gtl_tile_id`, `btl_tile_id`, `collision`, `event_id`, `event_word`, `marked`, `btl_pass_suppressed`, `no_combat_zone` | Per-tile ground, roof, collision, and event data. Both derived flags use `(event_word & 0x0FC00000) == 0x00400000`. | | `map_objects` | `map_id`, `object_index`, `x`, `y`, `btl_tile_id`, `stack_order` | Building tile stacks | | `map_sprites` | `map_id`, `sprite_index`, `x`, `y`, `sprite_id` | Embedded sprite placements | diff --git a/src/map/mod.rs b/src/map/mod.rs index 280d5d25..f49387ba 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -772,6 +772,14 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { .get(&coords) .map(|e| e.is_tile_marked()) .unwrap_or_default(); + let btl_pass_suppressed = events + .get(&coords) + .map(|e| e.suppresses_btl_pass()) + .unwrap_or_default(); + let no_combat_zone = events + .get(&coords) + .map(|e| e.is_no_combat_zone()) + .unwrap_or_default(); let object_id = object_ids.get(&coords).copied(); let raw_ref = access_ref_words.get(&coords).copied().unwrap_or(0); let shadow_level = ((raw_ref >> 15) & 0x7FFF) as i64; @@ -788,6 +796,8 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { event_id as i32, event_word, marked, + btl_pass_suppressed, + no_combat_zone, object_id, shadow_level, light_flags, @@ -1260,9 +1270,9 @@ mod db_export_tests { (w - 1, 0), ] { let coords = (sx, sy); - let tile_row: [Option; 6] = conn + let tile_row: [Option; 8] = conn .query_row( - "SELECT event_word, marked, object_id, shadow_level, light_flags, \ + "SELECT event_word, marked, btl_pass_suppressed, no_combat_zone, object_id, shadow_level, light_flags, \ access_ref_word FROM map_tiles WHERE x = ?1 AND y = ?2", params![sx - offset_x, sy - offset_y], |row| { @@ -1273,10 +1283,12 @@ mod db_export_tests { row.get(3)?, row.get(4)?, row.get(5)?, + row.get(6)?, + row.get(7)?, ]) }, ) - .unwrap_or([None, None, None, None, None, None]); + .unwrap_or([None, None, None, None, None, None, None, None]); // unwrap_or would swallow a missing row; require presence: if tile_row[0].is_none() && !data.events.contains_key(&coords) { panic!("missing tile row at {:?}", coords); @@ -1288,6 +1300,16 @@ mod db_export_tests { .get(&coords) .map(|e| e.is_tile_marked()) .unwrap_or(false); + let btl_pass_suppressed = data + .events + .get(&coords) + .map(|e| e.suppresses_btl_pass()) + .unwrap_or(false); + let no_combat_zone = data + .events + .get(&coords) + .map(|e| e.is_no_combat_zone()) + .unwrap_or(false); let raw_ref = data.access_ref_words.get(&coords).copied().unwrap_or(0); let expected_object = data.object_ids.get(&coords).map(|&v| v as i64); @@ -1298,21 +1320,33 @@ mod db_export_tests { coords ); assert_eq!(tile_row[1].unwrap() != 0, marked, "marked at {:?}", coords); - assert_eq!(tile_row[2], expected_object, "object_id at {:?}", coords); assert_eq!( - tile_row[3].unwrap(), + tile_row[2].unwrap() != 0, + btl_pass_suppressed, + "btl_pass_suppressed at {:?}", + coords + ); + assert_eq!( + tile_row[3].unwrap() != 0, + no_combat_zone, + "no_combat_zone at {:?}", + coords + ); + assert_eq!(tile_row[4], expected_object, "object_id at {:?}", coords); + assert_eq!( + tile_row[5].unwrap(), ((raw_ref >> 15) & 0x7FFF) as i64, "shadow_level at {:?}", coords ); assert_eq!( - tile_row[4].unwrap(), + tile_row[6].unwrap(), ((raw_ref >> 30) & 0x3) as i64, "light_flags at {:?}", coords ); assert_eq!( - tile_row[5].unwrap(), + tile_row[7].unwrap(), raw_ref as i64, "access_ref_word at {:?}", coords diff --git a/src/map/types.rs b/src/map/types.rs index 81d91f84..ec726de2 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -98,6 +98,52 @@ impl EventBlock { pub fn is_tile_marked(&self) -> bool { self.word & 0x0040_0000 != 0 } + + /// Returns true when the event word matches the BTL-pass mask. + pub fn suppresses_btl_pass(&self) -> bool { + (self.word & 0x0FC0_0000) == 0x0040_0000 + } + + /// Returns the same derived value under the no-combat-zone label. + pub fn is_no_combat_zone(&self) -> bool { + self.suppresses_btl_pass() + } +} + +#[cfg(test)] +mod event_block_tests { + use super::EventBlock; + + #[test] + fn btl_pass_suppression_requires_the_exact_high_bits() { + let plain = EventBlock { + x: 0, + y: 0, + word: 0x0040_0000, + }; + assert!(plain.suppresses_btl_pass()); + assert!(plain.is_no_combat_zone()); + let event_payload = EventBlock { + x: 0, + y: 0, + word: 0x0040_0013, + }; + assert!(event_payload.suppresses_btl_pass()); + let merely_unmarked = EventBlock { + x: 0, + y: 0, + word: 0, + }; + assert!(!merely_unmarked.suppresses_btl_pass()); + let marked_with_high_flag = EventBlock { + x: 0, + y: 0, + word: 0x00C0_0000, + }; + assert!(marked_with_high_flag.is_tile_marked()); + assert!(!marked_with_high_flag.suppresses_btl_pass()); + assert!(!marked_with_high_flag.is_no_combat_zone()); + } } /// Placement record for a sprite embedded directly in the map file. diff --git a/src/queries/create_table_map_tiles.sql b/src/queries/create_table_map_tiles.sql index 4685b04c..e19ea2d7 100644 --- a/src/queries/create_table_map_tiles.sql +++ b/src/queries/create_table_map_tiles.sql @@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS map_tiles ( event_id INTEGER, event_word INTEGER NOT NULL DEFAULT 0, marked BOOLEAN NOT NULL DEFAULT FALSE, + btl_pass_suppressed BOOLEAN NOT NULL DEFAULT FALSE, + no_combat_zone BOOLEAN NOT NULL DEFAULT FALSE, object_id INTEGER, shadow_level INTEGER NOT NULL DEFAULT 0, light_flags INTEGER NOT NULL DEFAULT 0, diff --git a/src/queries/insert_map_tile.sql b/src/queries/insert_map_tile.sql index 8ef16a65..2092f2bb 100644 --- a/src/queries/insert_map_tile.sql +++ b/src/queries/insert_map_tile.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id, event_word, marked, object_id, shadow_level, light_flags, access_ref_word) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13); +INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id, event_word, marked, btl_pass_suppressed, no_combat_zone, object_id, shadow_level, light_flags, access_ref_word) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15); From 6d2097d877bd593986120c0ddddf297fed3c4ba4 Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:43:59 +0200 Subject: [PATCH 48/49] Show no combat zones --- .../src/components/map_render/view_state.rs | 9 ++ .../map_editor/canvas/render_overlays.rs | 87 +++++++++++++++++ dispel-gui/src/editors/map_editor/message.rs | 3 + .../src/editors/map_editor/update/map.rs | 6 ++ .../src/editors/map_editor/update/mod.rs | 10 ++ dispel-gui/src/editors/map_editor/view/mod.rs | 24 +++++ dispel-gui/src/tests/map_editor.rs | 62 ++++++++---- dispel-gui/src/tests/sprite_viewer.rs | 2 + docs/files/Map/Map.map.md | 22 +++-- src/map/mod.rs | 80 ++++++++++----- src/map/types.rs | 97 ++++++++++++++++--- src/queries/create_table_map_tiles.sql | 5 +- src/queries/insert_map_tile.sql | 4 +- 13 files changed, 348 insertions(+), 63 deletions(-) diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs index 7d971648..a3fb4a61 100644 --- a/dispel-gui/src/components/map_render/view_state.rs +++ b/dispel-gui/src/components/map_render/view_state.rs @@ -20,6 +20,12 @@ pub struct MapViewState { pub show_internal_sprites: bool, pub show_collisions: bool, pub show_events: bool, + /// Tiles whose event word marks the player as being under a roof. + pub show_under_roof_markers: bool, + /// Player/camera anchors where a native visible-area scan blocks a spell cast. + pub show_no_spell_cast_zones: bool, + /// Roof overlays that can draw on dark maps. + pub show_btl_overlays_in_dark_maps: bool, pub show_monsters: bool, pub show_npcs: bool, pub show_npc_waypoints: bool, @@ -75,6 +81,9 @@ impl Default for MapViewState { show_internal_sprites: true, show_collisions: false, show_events: false, + show_under_roof_markers: false, + show_no_spell_cast_zones: false, + show_btl_overlays_in_dark_maps: false, show_monsters: true, show_npcs: true, show_npc_waypoints: false, diff --git a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs index f1377268..1cd7ab36 100644 --- a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs +++ b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs @@ -20,6 +20,36 @@ pub struct MapCanvasOverlaysLayer<'a> { pub tab_id: usize, } +/// Return the full isometric diamond for one map tile. +fn tile_diamond_path(px: f32, py: f32, zoom: f32) -> canvas::Path { + let width = TILE_W * zoom; + let height = TILE_H * zoom; + let center_x = px + width * 0.5; + let center_y = py + height * 0.5; + canvas::Path::new(|builder| { + builder.move_to(Point::new(center_x, center_y - height * 0.5)); + builder.line_to(Point::new(center_x + width * 0.5, center_y)); + builder.line_to(Point::new(center_x, center_y + height * 0.5)); + builder.line_to(Point::new(center_x - width * 0.5, center_y)); + builder.close(); + }) +} + +/// True when the native visible-area spell gate blocks a cast from `anchor`. +/// This inverts the map marker view: it identifies the player/camera anchors +/// from which at least one no-spell marker is visible. +fn no_spell_cast_is_blocked_at_anchor( + map_data: &dispel_core::map::MapData, + anchor: (i32, i32), +) -> bool { + dispel_core::map::no_spell_cast_zone_blocks_at_anchor(anchor, |coords| { + map_data + .events + .get(&coords) + .is_some_and(|event| event.is_no_spell_cast_zone()) + }) +} + impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { type State = MapCanvasState; @@ -131,6 +161,63 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> { } } + // Player-under-roof marker — amber fill. The renderer uses + // this marker at the player tile to hide the BTL pass. + if self.state.view.show_under_roof_markers { + for (&(tx, ty), event) in &map_data.events { + if !event.is_under_roof_marker() { + continue; + } + let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); + if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) { + continue; + } + frame.fill( + &tile_diamond_path(px, py, zoom), + Color::from_rgba(1.0, 0.55, 0.05, 0.32), + ); + } + } + + // No-spell-casting range — violet fill. Unlike the amber + // source markers, this shows every player/camera anchor + // whose native 500-tile visible diamond contains a marker. + if self.state.view.show_no_spell_cast_zones { + for &(tx, ty) in map_data.events.keys() { + let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); + if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) { + continue; + } + if !no_spell_cast_is_blocked_at_anchor(map_data, (tx, ty)) { + continue; + } + frame.fill( + &tile_diamond_path(px, py, zoom), + Color::from_rgba(0.8, 0.15, 0.95, 0.25), + ); + } + } + + // Conditional roof overlays — teal outline. These access + // references can draw in the dark-map roof pass. + if self.state.view.show_btl_overlays_in_dark_maps { + for (&(tx, ty), &access_ref_word) in &map_data.access_ref_words { + if !dispel_core::map::btl_overlay_draws_in_dark_map(access_ref_word) { + continue; + } + let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom); + if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) { + continue; + } + frame.stroke( + &tile_diamond_path(px, py, zoom), + canvas::Stroke::default() + .with_color(Color::from_rgba(0.05, 0.85, 0.78, 0.95)) + .with_width((2.0 * zoom).max(1.0)), + ); + } + } + // Object ID overlay — cyan diamond + label for non-zero object slots if self.state.view.show_object_ids { for (&(tx, ty), &obj_id) in &map_data.object_ids { diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs index 313813fd..19070e2a 100644 --- a/dispel-gui/src/editors/map_editor/message.rs +++ b/dispel-gui/src/editors/map_editor/message.rs @@ -253,6 +253,9 @@ pub enum MapLayer { InternalSprites, Collisions, Events, + UnderRoofMarkers, + NoSpellCastZones, + BtlOverlaysInDarkMaps, Monsters, Npcs, NpcWaypoints, diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs index 69d7f359..a1049600 100644 --- a/dispel-gui/src/editors/map_editor/update/map.rs +++ b/dispel-gui/src/editors/map_editor/update/map.rs @@ -29,6 +29,9 @@ pub fn layer_visible(view: &MapViewState, layer: MapLayer) -> bool { MapLayer::InternalSprites => view.show_internal_sprites, MapLayer::Collisions => view.show_collisions, MapLayer::Events => view.show_events, + MapLayer::UnderRoofMarkers => view.show_under_roof_markers, + MapLayer::NoSpellCastZones => view.show_no_spell_cast_zones, + MapLayer::BtlOverlaysInDarkMaps => view.show_btl_overlays_in_dark_maps, MapLayer::Monsters => view.show_monsters, MapLayer::Npcs => view.show_npcs, MapLayer::NpcWaypoints => view.show_npc_waypoints, @@ -47,6 +50,9 @@ fn set_layer_visible(view: &mut MapViewState, layer: MapLayer, visible: bool) { MapLayer::InternalSprites => view.show_internal_sprites = visible, MapLayer::Collisions => view.show_collisions = visible, MapLayer::Events => view.show_events = visible, + MapLayer::UnderRoofMarkers => view.show_under_roof_markers = visible, + MapLayer::NoSpellCastZones => view.show_no_spell_cast_zones = visible, + MapLayer::BtlOverlaysInDarkMaps => view.show_btl_overlays_in_dark_maps = visible, MapLayer::Monsters => view.show_monsters = visible, MapLayer::Npcs => view.show_npcs = visible, MapLayer::NpcWaypoints => view.show_npc_waypoints = visible, diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs index 2ba8d6a0..05ce9600 100644 --- a/dispel-gui/src/editors/map_editor/update/mod.rs +++ b/dispel-gui/src/editors/map_editor/update/mod.rs @@ -189,6 +189,16 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task { state.view.show_collisions = !state.view.show_collisions } MapLayer::Events => state.view.show_events = !state.view.show_events, + MapLayer::UnderRoofMarkers => { + state.view.show_under_roof_markers = !state.view.show_under_roof_markers + } + MapLayer::NoSpellCastZones => { + state.view.show_no_spell_cast_zones = !state.view.show_no_spell_cast_zones + } + MapLayer::BtlOverlaysInDarkMaps => { + state.view.show_btl_overlays_in_dark_maps = + !state.view.show_btl_overlays_in_dark_maps + } MapLayer::Monsters => state.view.show_monsters = !state.view.show_monsters, MapLayer::Npcs => state.view.show_npcs = !state.view.show_npcs, MapLayer::NpcWaypoints => { diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs index 90fbfc68..213ee520 100644 --- a/dispel-gui/src/editors/map_editor/view/mod.rs +++ b/dispel-gui/src/editors/map_editor/view/mod.rs @@ -143,6 +143,27 @@ pub fn view(app: &App) -> Element<'_, Message> { MapLayer::Events, None ), + layer_toggle( + "Under Roof / Hide BTL", + state.view.show_under_roof_markers, + tab_id, + MapLayer::UnderRoofMarkers, + None + ), + layer_toggle( + "No Spell Casting Range", + state.view.show_no_spell_cast_zones, + tab_id, + MapLayer::NoSpellCastZones, + None + ), + layer_toggle( + "BTL Overlays in Dark Maps", + state.view.show_btl_overlays_in_dark_maps, + tab_id, + MapLayer::BtlOverlaysInDarkMaps, + None + ), layer_toggle( "Object IDs", state.view.show_object_ids, @@ -200,6 +221,9 @@ pub fn view(app: &App) -> Element<'_, Message> { state.view.show_internal_sprites, state.view.show_collisions, state.view.show_events, + state.view.show_under_roof_markers, + state.view.show_no_spell_cast_zones, + state.view.show_btl_overlays_in_dark_maps, state.view.show_monsters, state.view.show_npcs, state.view.show_npc_waypoints, diff --git a/dispel-gui/src/tests/map_editor.rs b/dispel-gui/src/tests/map_editor.rs index e8970604..a7a555b7 100644 --- a/dispel-gui/src/tests/map_editor.rs +++ b/dispel-gui/src/tests/map_editor.rs @@ -638,6 +638,49 @@ mod map_editor_entity_tests { ); } + #[test] + fn derived_map_overlay_layers_default_hidden_and_toggle_independently() { + let mut app = app_with_map_editor(); + let tab_id = 0; + let view = &app.state.editors.map_editors[&tab_id].view; + assert!(!view.show_under_roof_markers); + assert!(!view.show_no_spell_cast_zones); + assert!(!view.show_btl_overlays_in_dark_maps); + + let _ = map_editor::handle( + MapEditorMessage::LayerToggled(tab_id, MapLayer::UnderRoofMarkers), + &mut app, + ); + assert!( + app.state.editors.map_editors[&tab_id] + .view + .show_under_roof_markers + ); + assert!( + !app.state.editors.map_editors[&tab_id] + .view + .show_no_spell_cast_zones + ); + assert!( + !app.state.editors.map_editors[&tab_id] + .view + .show_btl_overlays_in_dark_maps + ); + + let _ = map_editor::handle( + MapEditorMessage::LayerToggled(tab_id, MapLayer::NoSpellCastZones), + &mut app, + ); + let _ = map_editor::handle( + MapEditorMessage::LayerToggled(tab_id, MapLayer::BtlOverlaysInDarkMaps), + &mut app, + ); + let view = &app.state.editors.map_editors[&tab_id].view; + assert!(view.show_under_roof_markers); + assert!(view.show_no_spell_cast_zones); + assert!(view.show_btl_overlays_in_dark_maps); + } + #[test] fn light_map_loads_no_shadow_data() { let Some(gp) = fixture_game_path() else { @@ -739,23 +782,4 @@ mod map_editor_entity_tests { ); } } - - /// GUI hit-testing and the CLI renderer must pick the same standing spot - /// for every NPC record in the fixture set. - #[test] - fn gui_npc_placement_matches_core_renderer() { - use crate::editors::map_editor::canvas::hit_test::npc_pos; - let gp = std::path::PathBuf::from("../fixtures/Dispel"); - if !gp.exists() { - eprintln!("Skipping: fixtures not found"); - return; - } - use dispel_core::references::extractor::Extractor; - let npcs = dispel_core::NPC::read_file(&gp.join("NpcInGame/Npccat1.ref")) - .expect("parse npccat1.ref"); - assert!(!npcs.is_empty()); - for n in &npcs { - assert_eq!(npc_pos(n), dispel_core::map::render::npc_placement(n)); - } - } } diff --git a/dispel-gui/src/tests/sprite_viewer.rs b/dispel-gui/src/tests/sprite_viewer.rs index 4e379bef..150bfc74 100644 --- a/dispel-gui/src/tests/sprite_viewer.rs +++ b/dispel-gui/src/tests/sprite_viewer.rs @@ -48,6 +48,7 @@ mod sprite_viewer_tests { save_path: PathBuf::new(), name: "test.spr".into(), error: None, + preserve_black: false, sprite_file: None, dirty: false, undo_stack: Vec::new(), @@ -90,6 +91,7 @@ mod sprite_viewer_tests { save_path: PathBuf::new(), name: "test.spr".into(), error: None, + preserve_black: false, sprite_file: None, dirty: false, undo_stack: Vec::new(), diff --git a/docs/files/Map/Map.map.md b/docs/files/Map/Map.map.md index 2c1b8268..4eff1f71 100644 --- a/docs/files/Map/Map.map.md +++ b/docs/files/Map/Map.map.md @@ -182,15 +182,19 @@ flags: i16 -- high half: parameter/flag bits In the packed u32, bit 22 sets `EventBlock::is_tile_marked`. -`EventBlock::suppresses_btl_pass` returns true only when this expression is -true: +Two game systems use the following exact event-word predicate: ```text (word & 0x0FC00000) == 0x00400000 ``` -`EventBlock::is_no_combat_zone` returns the same derived value for callers -that use this label. The remaining high bits have no documented meaning. +- `EventBlock::is_under_roof_marker` identifies a player tile that hides the + BTL pass. +- `EventBlock::is_no_spell_cast_zone` identifies a visible tile that prevents + a player from preparing a spell. + +The predicate is the same, but the systems use different tile scopes. The +remaining high bits have no documented meaning. ### Tile & Access Block @@ -229,8 +233,14 @@ This grid packs **two layers** in one u32: 2. **Shadow/fog level** (bits 15–29): 0–199 darkness applied per tile via a fade table; entities carrying light raise the value (max wins). -Bit 15 of the word bleeds into the signed `ref_id` i16, which is why readers -skip negative ids. +On a dark map, a BTL overlay must have a non-zero reference and at least one +bit in positions 15–31. The extractor exposes this condition as +`btl_overlay_draws_in_dark_map`. It is not a guarantee that the overlay draws: +the referenced Second Block entry must also have its draw-enable byte set. +Light maps use a different roof pass and do not apply this extra condition. + +Bit 15 overlaps the signed-i16 view of `ref_id`; readers must mask the low 15 +bits instead of treating a negative i16 value as an absent reference. --- diff --git a/src/map/mod.rs b/src/map/mod.rs index f49387ba..7950309a 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -120,7 +120,8 @@ pub use render::{EntityRenderInfo, ExternalEntities, LayerToggles}; pub use types::{ BundleEntry, BundleItem, BundleRecord, Coords, EventBlock, SpriteInfoBlock, StackLevelFlags, TILE_HEIGHT_HALF, TILE_HORIZONTAL_OFFSET_HALF, TILE_PIXEL_NUMBER, TILE_WIDTH_HALF, TiledBundle, - TiledObjectInfo, TiledObjectRef, convert_map_coords_to_image_coords, + TiledObjectInfo, TiledObjectRef, btl_overlay_draws_in_dark_map, + convert_map_coords_to_image_coords, no_spell_cast_zone_blocks_at_anchor, }; use std::collections::HashMap; @@ -772,16 +773,17 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { .get(&coords) .map(|e| e.is_tile_marked()) .unwrap_or_default(); - let btl_pass_suppressed = events + let under_roof_marker = events .get(&coords) - .map(|e| e.suppresses_btl_pass()) + .map(|e| e.is_under_roof_marker()) .unwrap_or_default(); - let no_combat_zone = events + let no_spell_cast_zone = events .get(&coords) - .map(|e| e.is_no_combat_zone()) + .map(|e| e.is_no_spell_cast_zone()) .unwrap_or_default(); let object_id = object_ids.get(&coords).copied(); let raw_ref = access_ref_words.get(&coords).copied().unwrap_or(0); + let btl_overlay_draws_in_dark_map = btl_overlay_draws_in_dark_map(raw_ref); let shadow_level = ((raw_ref >> 15) & 0x7FFF) as i64; let light_flags = ((raw_ref >> 30) & 0x3) as i64; let access_ref_word = raw_ref as i64; @@ -796,8 +798,9 @@ pub fn save_map_tiles(params: SaveMapTilesParams) -> DbResult<()> { event_id as i32, event_word, marked, - btl_pass_suppressed, - no_combat_zone, + under_roof_marker, + no_spell_cast_zone, + btl_overlay_draws_in_dark_map, object_id, shadow_level, light_flags, @@ -1212,6 +1215,30 @@ mod db_export_tests { conn.query_row(sql, [], |row| row.get(0)).unwrap() } + #[test] + fn map_tiles_schema_uses_precise_spell_and_roof_columns() { + let conn = setup_db(); + let mut stmt = conn.prepare("PRAGMA table_info(map_tiles)").unwrap(); + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + for column in [ + "under_roof_marker", + "no_spell_cast_zone", + "btl_overlay_draws_in_dark_map", + ] { + assert!( + columns.iter().any(|name| name == column), + "missing {column}" + ); + } + assert!(!columns.iter().any(|name| name == "btl_pass_suppressed")); + assert!(!columns.iter().any(|name| name == "no_combat_zone")); + } + #[test] fn test_db_export_overlay_modes_match_parsed_table() { let (data, mut reader) = parse_fixture(); @@ -1270,9 +1297,10 @@ mod db_export_tests { (w - 1, 0), ] { let coords = (sx, sy); - let tile_row: [Option; 8] = conn + let tile_row: [Option; 9] = conn .query_row( - "SELECT event_word, marked, btl_pass_suppressed, no_combat_zone, object_id, shadow_level, light_flags, \ + "SELECT event_word, marked, under_roof_marker, no_spell_cast_zone, \ + btl_overlay_draws_in_dark_map, object_id, shadow_level, light_flags, \ access_ref_word FROM map_tiles WHERE x = ?1 AND y = ?2", params![sx - offset_x, sy - offset_y], |row| { @@ -1285,10 +1313,11 @@ mod db_export_tests { row.get(5)?, row.get(6)?, row.get(7)?, + row.get(8)?, ]) }, ) - .unwrap_or([None, None, None, None, None, None, None, None]); + .unwrap_or([None, None, None, None, None, None, None, None, None]); // unwrap_or would swallow a missing row; require presence: if tile_row[0].is_none() && !data.events.contains_key(&coords) { panic!("missing tile row at {:?}", coords); @@ -1300,17 +1329,18 @@ mod db_export_tests { .get(&coords) .map(|e| e.is_tile_marked()) .unwrap_or(false); - let btl_pass_suppressed = data + let under_roof_marker = data .events .get(&coords) - .map(|e| e.suppresses_btl_pass()) + .map(|e| e.is_under_roof_marker()) .unwrap_or(false); - let no_combat_zone = data + let no_spell_cast_zone = data .events .get(&coords) - .map(|e| e.is_no_combat_zone()) + .map(|e| e.is_no_spell_cast_zone()) .unwrap_or(false); let raw_ref = data.access_ref_words.get(&coords).copied().unwrap_or(0); + let btl_overlay_draws_in_dark_map = btl_overlay_draws_in_dark_map(raw_ref); let expected_object = data.object_ids.get(&coords).map(|&v| v as i64); assert_eq!( @@ -1322,31 +1352,37 @@ mod db_export_tests { assert_eq!(tile_row[1].unwrap() != 0, marked, "marked at {:?}", coords); assert_eq!( tile_row[2].unwrap() != 0, - btl_pass_suppressed, - "btl_pass_suppressed at {:?}", + under_roof_marker, + "under_roof_marker at {:?}", coords ); assert_eq!( tile_row[3].unwrap() != 0, - no_combat_zone, - "no_combat_zone at {:?}", + no_spell_cast_zone, + "no_spell_cast_zone at {:?}", + coords + ); + assert_eq!( + tile_row[4].unwrap() != 0, + btl_overlay_draws_in_dark_map, + "btl_overlay_draws_in_dark_map at {:?}", coords ); - assert_eq!(tile_row[4], expected_object, "object_id at {:?}", coords); + assert_eq!(tile_row[5], expected_object, "object_id at {:?}", coords); assert_eq!( - tile_row[5].unwrap(), + tile_row[6].unwrap(), ((raw_ref >> 15) & 0x7FFF) as i64, "shadow_level at {:?}", coords ); assert_eq!( - tile_row[6].unwrap(), + tile_row[7].unwrap(), ((raw_ref >> 30) & 0x3) as i64, "light_flags at {:?}", coords ); assert_eq!( - tile_row[7].unwrap(), + tile_row[8].unwrap(), raw_ref as i64, "access_ref_word at {:?}", coords diff --git a/src/map/types.rs b/src/map/types.rs index ec726de2..42c1d836 100644 --- a/src/map/types.rs +++ b/src/map/types.rs @@ -99,15 +99,56 @@ impl EventBlock { self.word & 0x0040_0000 != 0 } - /// Returns true when the event word matches the BTL-pass mask. - pub fn suppresses_btl_pass(&self) -> bool { + /// True when this tile marks the player as being under a roof. + /// + /// The renderer uses this exact high-bit predicate for the player's tile + /// before it skips the BTL pass. + pub fn is_under_roof_marker(&self) -> bool { + (self.word & 0x0FC0_0000) == 0x0040_0000 + } + + /// True when this tile belongs to the visible-area spell-casting gate. + /// + /// This has the same on-disk predicate as [`Self::is_under_roof_marker`], + /// but the game consumes it by scanning visible tiles before a spell arms. + pub fn is_no_spell_cast_zone(&self) -> bool { (self.word & 0x0FC0_0000) == 0x0040_0000 } +} + +/// True when a roof overlay can draw on a dark map. +/// +/// The low 15 bits select a BTL overlay. In dark-map rendering, the game also +/// requires at least one bit in the upper 17 bits (bits 15–31) to be set. +pub fn btl_overlay_draws_in_dark_map(access_ref_word: u32) -> bool { + (access_ref_word & 0x7FFF) != 0 && (access_ref_word & 0xFFFF_8000) != 0 +} - /// Returns the same derived value under the no-combat-zone label. - pub fn is_no_combat_zone(&self) -> bool { - self.suppresses_btl_pass() +/// Returns true when a spell-gate marker falls in the native visible diamond +/// rooted at `anchor`. +/// +/// The game scans 500 tiles in 32 rows before it allows a spell to arm. The +/// callback receives each map coordinate in that camera-relative footprint and +/// can stop the scan by returning true. +pub fn no_spell_cast_zone_blocks_at_anchor( + anchor: Coords, + mut is_no_spell_marker: impl FnMut(Coords) -> bool, +) -> bool { + const ROW_RUNS: [i32; 32] = [ + 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 27, 27, 27, 27, 27, 25, 23, 21, 19, 17, + 15, 13, 11, 9, 7, 5, 3, 1, + ]; + + for (y, &run) in ROW_RUNS.iter().enumerate() { + let y = y as i32; + let first_x = if y < 13 { -y } else { y - 26 }; + for x in first_x..first_x + run { + if is_no_spell_marker((anchor.0 + x, anchor.1 + y)) { + return true; + } + } } + false } #[cfg(test)] @@ -115,34 +156,66 @@ mod event_block_tests { use super::EventBlock; #[test] - fn btl_pass_suppression_requires_the_exact_high_bits() { + fn under_roof_and_no_spell_markers_require_the_exact_high_bits() { let plain = EventBlock { x: 0, y: 0, word: 0x0040_0000, }; - assert!(plain.suppresses_btl_pass()); - assert!(plain.is_no_combat_zone()); + assert!(plain.is_under_roof_marker()); + assert!(plain.is_no_spell_cast_zone()); let event_payload = EventBlock { x: 0, y: 0, word: 0x0040_0013, }; - assert!(event_payload.suppresses_btl_pass()); + assert!(event_payload.is_under_roof_marker()); + assert!(event_payload.is_no_spell_cast_zone()); let merely_unmarked = EventBlock { x: 0, y: 0, word: 0, }; - assert!(!merely_unmarked.suppresses_btl_pass()); + assert!(!merely_unmarked.is_under_roof_marker()); + assert!(!merely_unmarked.is_no_spell_cast_zone()); let marked_with_high_flag = EventBlock { x: 0, y: 0, word: 0x00C0_0000, }; assert!(marked_with_high_flag.is_tile_marked()); - assert!(!marked_with_high_flag.suppresses_btl_pass()); - assert!(!marked_with_high_flag.is_no_combat_zone()); + assert!(!marked_with_high_flag.is_under_roof_marker()); + assert!(!marked_with_high_flag.is_no_spell_cast_zone()); + } + + #[test] + fn dark_map_roof_overlay_requires_a_reference_and_upper_bits() { + assert!(!super::btl_overlay_draws_in_dark_map(0)); + assert!(!super::btl_overlay_draws_in_dark_map(7)); + assert!(super::btl_overlay_draws_in_dark_map(0x0000_8007)); + assert!(super::btl_overlay_draws_in_dark_map(0x4000_0007)); + } + + #[test] + fn visible_spell_gate_uses_the_native_500_tile_diamond() { + let anchor = (10, 20); + let mut scanned = 0; + assert!(!super::no_spell_cast_zone_blocks_at_anchor(anchor, |_| { + scanned += 1; + false + })); + assert_eq!(scanned, 500); + assert!(super::no_spell_cast_zone_blocks_at_anchor( + anchor, + |tile| tile == anchor + )); + assert!(super::no_spell_cast_zone_blocks_at_anchor(anchor, |tile| { + tile == (15, 51) + })); + assert!(!super::no_spell_cast_zone_blocks_at_anchor( + anchor, + |tile| { tile == (16, 51) } + )); } } diff --git a/src/queries/create_table_map_tiles.sql b/src/queries/create_table_map_tiles.sql index e19ea2d7..806ae8b2 100644 --- a/src/queries/create_table_map_tiles.sql +++ b/src/queries/create_table_map_tiles.sql @@ -8,8 +8,9 @@ CREATE TABLE IF NOT EXISTS map_tiles ( event_id INTEGER, event_word INTEGER NOT NULL DEFAULT 0, marked BOOLEAN NOT NULL DEFAULT FALSE, - btl_pass_suppressed BOOLEAN NOT NULL DEFAULT FALSE, - no_combat_zone BOOLEAN NOT NULL DEFAULT FALSE, + under_roof_marker BOOLEAN NOT NULL DEFAULT FALSE, + no_spell_cast_zone BOOLEAN NOT NULL DEFAULT FALSE, + btl_overlay_draws_in_dark_map BOOLEAN NOT NULL DEFAULT FALSE, object_id INTEGER, shadow_level INTEGER NOT NULL DEFAULT 0, light_flags INTEGER NOT NULL DEFAULT 0, diff --git a/src/queries/insert_map_tile.sql b/src/queries/insert_map_tile.sql index 2092f2bb..875037a0 100644 --- a/src/queries/insert_map_tile.sql +++ b/src/queries/insert_map_tile.sql @@ -1,2 +1,2 @@ -INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id, event_word, marked, btl_pass_suppressed, no_combat_zone, object_id, shadow_level, light_flags, access_ref_word) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15); +INSERT OR REPLACE INTO map_tiles (map_id, x, y, gtl_tile_id, btl_tile_id, collision, event_id, event_word, marked, under_roof_marker, no_spell_cast_zone, btl_overlay_draws_in_dark_map, object_id, shadow_level, light_flags, access_ref_word) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16); From 55c1de2de7f66e4beae39ae009f29db23c3555ab Mon Sep 17 00:00:00 2001 From: dimspell <141545384+dimspell@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:30:01 +0200 Subject: [PATCH 49/49] Reformat code --- dispel-gui/src/app.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs index 6f656542..47f46380 100644 --- a/dispel-gui/src/app.rs +++ b/dispel-gui/src/app.rs @@ -316,7 +316,6 @@ impl App { Task::none() } EditorType::FogDataEditor => { - // 63 KB file — synchronous parse, same as the sprite editor. if let Some(tab_id) = self.active_tab_id() { self.state .editors