use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use hiraku_script::hson;
use hiraku_storage::{ByteStorage, PlatformStorage};
use prost::Message;
use thiserror::Error;
use crate::{
proto,
state::{
AudioSnapshot, CURRENT_SAVE_VERSION, CameraSnapshot, DialogueSnapshot, ImageLayerSnapshot,
SaveCheckpoint, SaveGameData, SavedInput, SceneSnapshot, ScriptPosition, SpriteSnapshot,
StoredValue, TextEffectSnapshot,
},
vfs::workspace_base_path,
};
const SAVE_ROOT: &str = "saves";
const SAVE_EXTENSION: &str = "sav";
const SAVE_NAMESPACE: &str = "hiraku.save";
mod user_settings;
pub use user_settings::{UserSettings, read_user_settings, write_user_settings};
#[derive(Debug, Error)]
pub enum StorageError {
#[error(transparent)]
Backend(#[from] hiraku_storage::StorageError),
#[error("failed to load HSON data: {0}")]
HsonData(String),
#[error("failed to decode save protobuf: {0}")]
ProstDecode(#[from] prost::DecodeError),
#[error("invalid save data: {0}")]
InvalidSave(String),
#[error("slot name can only contain letters, digits, '-' or '_'")]
InvalidSlot,
#[error("save slot `{0}` does not exist")]
MissingSlot(String),
}
pub fn save_root_path() -> PathBuf {
workspace_base_path().join(SAVE_ROOT)
}
pub fn load_save_data(slot: &str) -> Result<SaveGameData, StorageError> {
load_save_data_from_root(&save_root_path(), slot)
}
pub fn load_save_data_from_root(root: &Path, slot: &str) -> Result<SaveGameData, StorageError> {
let slot = sanitize_slot_name(slot)?;
let payload = save_storage(root)
.read(slot)?
.ok_or_else(|| StorageError::MissingSlot(slot.to_string()))?;
decode_save_data(&payload)
}
pub fn write_save_data_to_root(
root: &Path,
slot: &str,
data: &SaveGameData,
) -> Result<(), StorageError> {
let slot = sanitize_slot_name(slot)?;
save_storage(root).write(slot, &encode_save_data(data))?;
Ok(())
}
fn save_storage(root: &Path) -> PlatformStorage {
PlatformStorage::new(root, SAVE_NAMESPACE, SAVE_EXTENSION)
}
fn encode_save_data(data: &SaveGameData) -> Vec<u8> {
proto::SaveGameData::from(data).encode_to_vec()
}
fn decode_save_data(payload: &[u8]) -> Result<SaveGameData, StorageError> {
proto::SaveGameData::decode(payload)?.try_into()
}
impl From<&SaveGameData> for proto::SaveGameData {
fn from(data: &SaveGameData) -> Self {
Self {
version: data.version,
resume_script: data.resume_script.clone(),
random_seed: data.random_seed,
time_seed: data.time_seed,
rng_state: data.rng_state.as_ref().map(Into::into),
checkpoint: data.checkpoint.as_ref().map(Into::into),
script_stack: data.script_stack.clone(),
globals: stored_entries_from_map(&data.globals),
scope: stored_entries_from_map(&data.scope),
input_log: data.input_log.iter().map(Into::into).collect(),
scene: Some((&data.scene).into()),
vm_snapshot_hson: data
.vm_snapshot
.as_ref()
.and_then(|snapshot| hson::to_vec(snapshot).ok())
.unwrap_or_default(),
pending_ui_screen: data.pending_ui_screen.clone(),
pending_ui_arguments_hson: hson::to_vec(&data.pending_ui_arguments)
.expect("pending UI arguments must serialize to HSON"),
script_call_stack_hson: hson::to_vec(&data.script_call_stack)
.expect("script call stack snapshots must serialize to HSON"),
ui_registry_hson: hson::to_vec(&data.ui_registry)
.expect("UI registry must serialize to HSON"),
mounted_ui_overlays_hson: hson::to_vec(&data.mounted_ui_overlays)
.expect("mounted UI overlays must serialize to HSON"),
}
}
}
impl TryFrom<proto::SaveGameData> for SaveGameData {
type Error = StorageError;
fn try_from(data: proto::SaveGameData) -> Result<Self, Self::Error> {
if data.version != CURRENT_SAVE_VERSION {
return Err(StorageError::InvalidSave(format!(
"save format version {} is incompatible with runtime version {}; create a new save after script API changes",
data.version, CURRENT_SAVE_VERSION
)));
}
Ok(Self {
version: data.version,
resume_script: data.resume_script,
random_seed: data.random_seed,
rng_state: data.rng_state.map(Into::into),
time_seed: data.time_seed,
checkpoint: data.checkpoint.map(TryInto::try_into).transpose()?,
script_stack: data.script_stack,
globals: stored_map_from_entries(data.globals)?,
scope: stored_map_from_entries(data.scope)?,
input_log: data
.input_log
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?,
scene: data
.scene
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
vm_snapshot: if data.vm_snapshot_hson.is_empty() || data.version < 7 {
None
} else {
Some(hson::from_slice(&data.vm_snapshot_hson).map_err(|error| {
StorageError::InvalidSave(format!("invalid HSON VM snapshot: {error}"))
})?)
},
pending_ui_screen: data.pending_ui_screen,
pending_ui_arguments: if data.pending_ui_arguments_hson.is_empty() {
Vec::new()
} else {
hson::from_slice(&data.pending_ui_arguments_hson).map_err(|error| {
StorageError::InvalidSave(format!("invalid pending UI arguments: {error}"))
})?
},
script_call_stack: if data.script_call_stack_hson.is_empty() {
Vec::new()
} else {
hson::from_slice(&data.script_call_stack_hson).map_err(|error| {
StorageError::InvalidSave(format!("invalid HSON script call stack: {error}"))
})?
},
ui_registry: if data.ui_registry_hson.is_empty() {
BTreeMap::new()
} else {
hson::from_slice(&data.ui_registry_hson).map_err(|error| {
StorageError::InvalidSave(format!("invalid HSON UI registry: {error}"))
})?
},
mounted_ui_overlays: if data.mounted_ui_overlays_hson.is_empty() {
BTreeMap::new()
} else {
hson::from_slice(&data.mounted_ui_overlays_hson).map_err(|error| {
StorageError::InvalidSave(format!("invalid HSON mounted UI overlays: {error}"))
})?
},
})
}
}
impl From<&crate::state::RngState> for proto::RngState {
fn from(state: &crate::state::RngState) -> Self {
Self {
state: state.state,
stream: state.stream,
}
}
}
impl From<proto::RngState> for crate::state::RngState {
fn from(state: proto::RngState) -> Self {
Self {
state: state.state,
stream: state.stream,
}
}
}
impl From<&SaveCheckpoint> for proto::SaveCheckpoint {
fn from(checkpoint: &SaveCheckpoint) -> Self {
Self {
script: checkpoint.script.clone(),
ordinal: checkpoint.ordinal,
kind: checkpoint.kind.clone(),
label: checkpoint.label.clone(),
position: Some((&checkpoint.position).into()),
}
}
}
impl TryFrom<proto::SaveCheckpoint> for SaveCheckpoint {
type Error = StorageError;
fn try_from(checkpoint: proto::SaveCheckpoint) -> Result<Self, Self::Error> {
Ok(Self {
script: checkpoint.script,
ordinal: checkpoint.ordinal,
kind: checkpoint.kind,
label: checkpoint.label,
position: checkpoint.position.map(Into::into).unwrap_or_default(),
})
}
}
impl From<&ScriptPosition> for proto::ScriptPosition {
fn from(position: &ScriptPosition) -> Self {
Self {
line: position.line.map(|value| value as u64),
column: position.column.map(|value| value as u64),
}
}
}
impl From<proto::ScriptPosition> for ScriptPosition {
fn from(position: proto::ScriptPosition) -> Self {
Self {
line: position.line.map(|value| value as usize),
column: position.column.map(|value| value as usize),
}
}
}
impl From<&SavedInput> for proto::SavedInput {
fn from(input: &SavedInput) -> Self {
Self {
checkpoint: Some((&input.checkpoint).into()),
value: Some((&input.value).into()),
}
}
}
impl TryFrom<proto::SavedInput> for SavedInput {
type Error = StorageError;
fn try_from(input: proto::SavedInput) -> Result<Self, Self::Error> {
Ok(Self {
checkpoint: input
.checkpoint
.ok_or_else(|| {
StorageError::InvalidSave("saved input missing checkpoint".to_string())
})?
.try_into()?,
value: input
.value
.ok_or_else(|| StorageError::InvalidSave("saved input missing value".to_string()))?
.try_into()?,
})
}
}
impl From<&StoredValue> for proto::StoredValue {
fn from(value: &StoredValue) -> Self {
use proto::stored_value::Kind;
let kind = match value {
StoredValue::Bool(value) => Kind::Bool(*value),
StoredValue::Int(value) => Kind::Int(*value),
StoredValue::Float(value) => Kind::Float(*value),
StoredValue::String(value) => Kind::String(value.clone()),
StoredValue::Array(values) => Kind::Array(proto::StoredArray {
values: values.iter().map(Into::into).collect(),
}),
StoredValue::Map(values) => Kind::Map(proto::StoredMap {
entries: stored_entries_from_map(values),
}),
};
Self { kind: Some(kind) }
}
}
impl TryFrom<proto::StoredValue> for StoredValue {
type Error = StorageError;
fn try_from(value: proto::StoredValue) -> Result<Self, Self::Error> {
use proto::stored_value::Kind;
match value
.kind
.ok_or_else(|| StorageError::InvalidSave("stored value missing kind".to_string()))?
{
Kind::Bool(value) => Ok(StoredValue::Bool(value)),
Kind::Int(value) => Ok(StoredValue::Int(value)),
Kind::Float(value) => Ok(StoredValue::Float(value)),
Kind::String(value) => Ok(StoredValue::String(value)),
Kind::Array(values) => values
.values
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()
.map(StoredValue::Array),
Kind::Map(values) => stored_map_from_entries(values.entries).map(StoredValue::Map),
}
}
}
impl From<&SceneSnapshot> for proto::SceneSnapshot {
fn from(scene: &SceneSnapshot) -> Self {
Self {
background: scene.background.as_ref().map(Into::into),
sprites: scene.sprites.iter().map(Into::into).collect(),
character_positions: scene
.character_positions
.iter()
.map(|(actor_id, position)| proto::CharacterPosition {
actor_id: actor_id.clone(),
x: position[0],
y: position[1],
})
.collect(),
overlay_alpha: scene.overlay_alpha,
bgm: scene.bgm.as_ref().map(Into::into),
dialogue: scene.dialogue.as_ref().map(Into::into),
text_effect: Some((&scene.text_effect).into()),
camera: Some((&scene.camera).into()),
}
}
}
impl TryFrom<proto::SceneSnapshot> for SceneSnapshot {
type Error = StorageError;
fn try_from(scene: proto::SceneSnapshot) -> Result<Self, Self::Error> {
Ok(Self {
background: scene.background.map(Into::into),
sprites: scene.sprites.into_iter().map(Into::into).collect(),
character_positions: scene
.character_positions
.into_iter()
.map(|position| (position.actor_id, [position.x, position.y]))
.collect(),
overlay_alpha: scene.overlay_alpha,
bgm: scene.bgm.map(Into::into),
dialogue: scene.dialogue.map(Into::into),
text_effect: scene.text_effect.map(Into::into).unwrap_or_default(),
camera: scene.camera.map(Into::into).unwrap_or_default(),
})
}
}
impl From<&CameraSnapshot> for proto::CameraSnapshot {
fn from(snapshot: &CameraSnapshot) -> Self {
Self {
blur: snapshot.blur,
zoom: snapshot.zoom,
offset: snapshot.offset.to_vec(),
rotation: snapshot.rotation.to_vec(),
projection: snapshot.projection.clone(),
scope: snapshot.scope.clone(),
}
}
}
impl From<proto::CameraSnapshot> for CameraSnapshot {
fn from(snapshot: proto::CameraSnapshot) -> Self {
let component = |values: &[f32], index| values.get(index).copied().unwrap_or(0.0);
Self {
blur: snapshot.blur,
zoom: if snapshot.zoom > 0.0 {
snapshot.zoom
} else {
1.0
},
offset: [
component(&snapshot.offset, 0),
component(&snapshot.offset, 1),
component(&snapshot.offset, 2),
],
rotation: [
component(&snapshot.rotation, 0),
component(&snapshot.rotation, 1),
component(&snapshot.rotation, 2),
],
projection: snapshot.projection,
scope: snapshot.scope,
}
}
}
impl From<&ImageLayerSnapshot> for proto::ImageLayerSnapshot {
fn from(snapshot: &ImageLayerSnapshot) -> Self {
Self {
path: snapshot.path.clone(),
}
}
}
impl From<proto::ImageLayerSnapshot> for ImageLayerSnapshot {
fn from(snapshot: proto::ImageLayerSnapshot) -> Self {
Self {
path: snapshot.path,
}
}
}
impl From<&SpriteSnapshot> for proto::SpriteSnapshot {
fn from(snapshot: &SpriteSnapshot) -> Self {
Self {
id: snapshot.id.clone(),
path: snapshot.path.clone(),
x: snapshot.x,
y: snapshot.y,
layer: snapshot.layer,
scale: snapshot.scale,
alpha: snapshot.alpha,
rect: snapshot.rect.map(Vec::from).unwrap_or_default(),
focused: snapshot.focused,
}
}
}
impl From<proto::SpriteSnapshot> for SpriteSnapshot {
fn from(snapshot: proto::SpriteSnapshot) -> Self {
Self {
id: snapshot.id,
path: snapshot.path,
x: snapshot.x,
y: snapshot.y,
layer: snapshot.layer,
scale: snapshot.scale,
alpha: snapshot.alpha,
rect: (snapshot.rect.len() == 4).then(|| {
[
snapshot.rect[0],
snapshot.rect[1],
snapshot.rect[2],
snapshot.rect[3],
]
}),
focused: snapshot.focused,
}
}
}
impl From<&AudioSnapshot> for proto::AudioSnapshot {
fn from(snapshot: &AudioSnapshot) -> Self {
Self {
path: snapshot.path.clone(),
volume: snapshot.volume,
}
}
}
impl From<proto::AudioSnapshot> for AudioSnapshot {
fn from(snapshot: proto::AudioSnapshot) -> Self {
Self {
path: snapshot.path,
volume: snapshot.volume,
}
}
}
impl From<&DialogueSnapshot> for proto::DialogueSnapshot {
fn from(snapshot: &DialogueSnapshot) -> Self {
Self {
speaker: snapshot.speaker.clone(),
text: snapshot.text.clone(),
}
}
}
impl From<proto::DialogueSnapshot> for DialogueSnapshot {
fn from(snapshot: proto::DialogueSnapshot) -> Self {
Self {
speaker: snapshot.speaker,
text: snapshot.text,
}
}
}
impl From<&TextEffectSnapshot> for proto::TextEffectSnapshot {
fn from(snapshot: &TextEffectSnapshot) -> Self {
Self {
mode: snapshot.mode.clone(),
cps: snapshot.cps,
fade_seconds: snapshot.fade_seconds,
}
}
}
impl From<proto::TextEffectSnapshot> for TextEffectSnapshot {
fn from(snapshot: proto::TextEffectSnapshot) -> Self {
Self {
mode: snapshot.mode,
cps: snapshot.cps,
fade_seconds: snapshot.fade_seconds,
}
}
}
fn stored_entries_from_map(values: &BTreeMap<String, StoredValue>) -> Vec<proto::StoredEntry> {
values
.iter()
.map(|(key, value)| proto::StoredEntry {
key: key.clone(),
value: Some(value.into()),
})
.collect()
}
fn stored_map_from_entries(
entries: Vec<proto::StoredEntry>,
) -> Result<BTreeMap<String, StoredValue>, StorageError> {
let mut values = BTreeMap::new();
for entry in entries {
let value = entry
.value
.ok_or_else(|| {
StorageError::InvalidSave(format!("stored entry `{}` missing value", entry.key))
})?
.try_into()?;
values.insert(entry.key, value);
}
Ok(values)
}
fn sanitize_slot_name(slot: &str) -> Result<&str, StorageError> {
if slot.is_empty() {
return Err(StorageError::InvalidSlot);
}
if slot
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
Ok(slot)
} else {
Err(StorageError::InvalidSlot)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::{StoryRuntime, StoryRuntimeEvent, compile_story_bytecode};
#[test]
fn new_save_data_defaults_to_the_current_format() {
assert_eq!(SaveGameData::default().version, CURRENT_SAVE_VERSION);
}
#[test]
fn rejects_bytecode_saves_from_an_incompatible_script_api() {
let data = SaveGameData {
version: CURRENT_SAVE_VERSION - 1,
resume_script: "story.hks".into(),
..Default::default()
};
let error = decode_save_data(&encode_save_data(&data))
.expect_err("old bytecode manifests must not reach the linker");
assert!(
error
.to_string()
.contains("incompatible with runtime version")
);
}
#[test]
fn save_roundtrip_preserves_exact_vm_wait_state() {
let source = "let name = \"guest\"\n\"save ${name} 🚀\"";
let bytecode =
compile_story_bytecode("save.story.hks", source).expect("save story must compile");
let mut runtime = StoryRuntime::new(bytecode).expect("runtime must initialize");
assert!(matches!(
runtime.step().expect("dialogue effect must run"),
Some(StoryRuntimeEvent::Effect(_))
));
assert!(matches!(
runtime.step().expect("dialogue wait must run"),
Some(StoryRuntimeEvent::Wait(_))
));
let snapshot = runtime.snapshot().expect("wait boundary must be saveable");
let data = SaveGameData {
version: CURRENT_SAVE_VERSION,
resume_script: "system.hks".to_string(),
vm_snapshot: Some(snapshot.clone()),
pending_ui_screen: Some("ui/title.ui.hks".to_string()),
pending_ui_arguments: vec![
StoredValue::String("Alice".to_string()),
StoredValue::Int(3),
],
ui_registry: BTreeMap::from([(
"dialogue".to_string(),
"hdp://main.hdp/ui/dialogue.ui.hks".to_string(),
)]),
mounted_ui_overlays: BTreeMap::from([("clock".to_string(), "clockHud".to_string())]),
..Default::default()
};
let restored = decode_save_data(&encode_save_data(&data)).unwrap();
assert_eq!(restored.vm_snapshot, Some(snapshot));
assert_eq!(
restored.pending_ui_screen.as_deref(),
Some("ui/title.ui.hks")
);
assert_eq!(restored.pending_ui_arguments, data.pending_ui_arguments);
assert_eq!(restored.ui_registry, data.ui_registry);
assert_eq!(restored.mounted_ui_overlays, data.mounted_ui_overlays);
let restored_bytecode = compile_story_bytecode("save.story.hks", source)
.expect("saved Unicode story must compile again");
StoryRuntime::restore(
restored_bytecode,
restored
.vm_snapshot
.expect("roundtrip must retain the VM snapshot"),
)
.expect("Unicode byte offsets must remain valid while restoring");
}
}