Preserve data across the Straywild transition

This commit is contained in:
Alexander Sellite 2026-08-25 22:18:20 -04:00
parent 737d1cf2e0
commit 98a27e2f53
28 changed files with 577 additions and 94 deletions

View file

@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc
Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io
Repo: https://forge.makearmy.io/woofmeow/straywild
Repo: https://forge.makearmy.io/woofmeow/netfishing
Releases: https://forge.makearmy.io/woofmeow/straywild/releases
Releases: https://forge.makearmy.io/woofmeow/netfishing/releases
## Installing release builds

View file

@ -47,6 +47,10 @@ directory. The templates in `scripts/portmaster/` are authoritative.
upgraded. Device-local data, configuration, and cache remain under
`straywild/conf/data`, `straywild/conf/config`, and
`straywild/conf/cache`.
- A rebrand upgrade copies an existing `netfishing/conf/` tree into the new
`straywild/conf/` tree when the latter does not exist. It also reuses an
existing `ports/saves/netfishing/` data root rather than creating an empty
replacement. New installations use straywild paths exclusively.
- The archive must not contain `conf/`, saves, identities, logs, source files,
`.git`, or `.godot` content.
- Release downloads must publish only the canonical `straywild.zip` for this
@ -101,9 +105,9 @@ device directory and run:
--offline --no-check install ./straywild.zip
```
Before upgrading an existing installation, preserve
`/mnt/mmc/ports/straywild/conf/` and `/mnt/mmc/ports/saves/straywild/` on the
device. After installation:
Before upgrading an existing installation, preserve either the current
`/mnt/mmc/ports/straywild/` paths or the former `/mnt/mmc/ports/netfishing/`
paths on the device. After installation:
1. Confirm the installed executable and PCK hashes match the staged release.
2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to

View file

@ -17,10 +17,10 @@ for local README files.
## Related repositories
- [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md)
- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md)
stays with the service implementation and defines its versioned HTTP
contract.
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/straywild-dedicated-server/src/branch/main/README.md)
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md)
stays with its installer and update scripts. It consumes this repository's
attribution and legal notices from the exact pinned game commit instead of
maintaining copies.

View file

@ -105,7 +105,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
application/bundle_identifier="io.woofmeow.straywild"
application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/straywild_1024.png"
application/icon_interpolation=0
application/short_version="0.18.1"
@ -178,7 +178,7 @@ architectures/x86=false
architectures/x86_64=false
version/code=180100
version/name="v0.18.1-alpha"
package/unique_name="io.woofmeow.straywild"
package/unique_name="io.woofmeow.netfishing"
package/name="straywild"
package/signed=true
package/app_category=0

View file

@ -277,6 +277,7 @@ var _rain_ambience: RainAmbienceType
func _ready() -> void:
_data_root.prepare_legacy_user_data()
_performance_profile = RuntimePerformanceProfileType.from_environment()
_dedicated_runtime = _is_dedicated_server_runtime()
if _dedicated_runtime:
@ -471,10 +472,7 @@ func _start_dedicated_server() -> void:
_fail_dedicated_server(config.error_message)
return
var data_path: String = config.data_directory
var manifest_path: String = data_path.path_join(
PlayerDataRoot.MANIFEST_FILENAME
)
if not FileAccess.file_exists(manifest_path):
if not _data_root.has_data_manifest(data_path):
var created: Dictionary = _data_root.create_unbound_root(data_path)
if not bool(created.get("ok", false)):
_fail_dedicated_server(str(created.get(
@ -973,6 +971,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_session,
_interface_fonts,
)
_game_ui.data_root_changed.connect(_on_data_root_changed)
_game_ui.setup_controller_mapping(_controller_mapping_manager)
_ui_pixelation.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager)
@ -1344,6 +1343,18 @@ func _on_data_root_status(message: String) -> void:
_configure_popup_dialog.call_deferred(dialog, dialog.get_ok_button())
func _on_data_root_changed() -> void:
_save_manager.set_autosave_enabled(false)
_save_manager.cancel_pending_autosave()
_configure_portable_stores()
if not _network_profile_service.reload_persisted_profile():
push_warning("The selected data folder's player profile could not be loaded.")
_network_session.set_local_appearance_snapshot(
_appearance_store.get_snapshot()
)
_game_ui.get_title_screen().refresh_after_data_root_change()
func _on_server_trust_required(
endpoint: String,
expected_fingerprint: String,

View file

@ -5,6 +5,10 @@ const PROFILE_ENVIRONMENT_VARIABLE: String = (
"straywild_PERFORMANCE_PROFILE"
)
const LEGACY_LIGHT_ENVIRONMENT_VARIABLE: String = "straywild_LOW_END"
const NETFISHING_PROFILE_ENVIRONMENT_VARIABLE := (
"NETFISHING_PERFORMANCE_PROFILE"
)
const NETFISHING_LIGHT_ENVIRONMENT_VARIABLE := "NETFISHING_LOW_END"
const NORMAL_PROFILE: StringName = &"normal"
const LIGHT_PROFILE: StringName = &"light"
const NORMAL_WORLD_RENDER_SCALE: float = 1.0
@ -14,9 +18,19 @@ var _profile_name: StringName = NORMAL_PROFILE
static func from_environment() -> RuntimePerformanceProfile:
var profile_name: String = OS.get_environment(
PROFILE_ENVIRONMENT_VARIABLE
)
if profile_name.is_empty():
profile_name = OS.get_environment(
NETFISHING_PROFILE_ENVIRONMENT_VARIABLE
)
return from_name(
StringName(OS.get_environment(PROFILE_ENVIRONMENT_VARIABLE)),
OS.get_environment(LEGACY_LIGHT_ENVIRONMENT_VARIABLE) == "1",
StringName(profile_name),
(
OS.get_environment(LEGACY_LIGHT_ENVIRONMENT_VARIABLE) == "1"
or OS.get_environment(NETFISHING_LIGHT_ENVIRONMENT_VARIABLE) == "1"
),
)

View file

@ -21,6 +21,7 @@ signal friend_invite_finished(success: bool, message: String)
const BASE_URL_SETTING: String = "network/discovery/base_url"
const BASE_URL_ENVIRONMENT: String = "straywild_DISCOVERY_URL"
const LEGACY_BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL"
const SETTINGS_PATH: String = "user://network_discovery.cfg"
const DEFAULT_ROOM_NAME: String = "Player's Server"
const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room"
@ -553,6 +554,10 @@ func is_own_room(room: Dictionary) -> bool:
func _configured_base_url() -> String:
var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges()
if value.is_empty():
value = OS.get_environment(
LEGACY_BASE_URL_ENVIRONMENT
).strip_edges()
if value.is_empty():
value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges()
while value.ends_with("/"):

View file

@ -17,6 +17,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
_namespaces.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool:

View file

@ -4,6 +4,7 @@ extends Node
signal operation_finished(success: bool, message: String)
const MAGIC := "straywild_IDENTITY_BACKUP"
const LEGACY_MAGIC := "NETFISHING_IDENTITY_BACKUP"
const FORMAT_VERSION := 1
const MAX_BACKUP_BYTES := 256 * 1024
const MIN_PASSPHRASE_LENGTH := 12
@ -110,8 +111,9 @@ func inspect_backup(
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
return {"ok": false}
var data: Dictionary = json.data
var legacy_backup: bool = data.get("magic") == LEGACY_MAGIC
if (
data.get("magic") != MAGIC
data.get("magic") not in [MAGIC, LEGACY_MAGIC]
or data.get("format_version") != FORMAT_VERSION
or data.get("identity_type") != expected_type
or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
@ -134,12 +136,23 @@ func inspect_backup(
var signature: PackedByteArray = Marshalls.base64_to_raw(
str(data.get("self_signature", ""))
)
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"identity_backup_self_test",
[expected_type, fingerprint],
signature,
):
var public_key: CryptoKey = NetworkIdentityCrypto.load_public_key(public_pem)
var backup_signature_valid: bool = (
NetworkIdentityCrypto.verify_legacy_fields(
public_key,
"identity_backup_self_test",
[expected_type, fingerprint],
signature,
)
if legacy_backup
else NetworkIdentityCrypto.verify_fields(
public_key,
"identity_backup_self_test",
[expected_type, fingerprint],
signature,
)
)
if not backup_signature_valid:
return {"ok": false}
var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields(
key, "identity_import_self_test", [fingerprint]

View file

@ -15,6 +15,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
_records.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func observe(fingerprint: String, display_name: String) -> String:

View file

@ -6,6 +6,7 @@ const FINGERPRINT_LENGTH: int = 64
const MAX_PUBLIC_KEY_BYTES: int = 8192
const MAX_SIGNATURE_BYTES: int = 1024
const DOMAIN_PREFIX: String = "straywild"
const LEGACY_DOMAIN_PREFIX: String = "NETFISHING"
const IDENTITY_VERSION: String = "identity_v1"
@ -50,8 +51,16 @@ static func secure_id(byte_count: int = 16) -> String:
static func canonical_bytes(domain: String, fields: Array) -> PackedByteArray:
return canonical_bytes_for_prefix(DOMAIN_PREFIX, domain, fields)
static func canonical_bytes_for_prefix(
prefix: String,
domain: String,
fields: Array,
) -> PackedByteArray:
var output := PackedByteArray()
_append_string(output, DOMAIN_PREFIX)
_append_string(output, prefix)
_append_string(output, IDENTITY_VERSION)
_append_string(output, domain)
for value: Variant in fields:
@ -117,6 +126,37 @@ static func verify_fields(
)
static func verify_legacy_fields(
public_key: CryptoKey,
domain: String,
fields: Array,
signature: PackedByteArray,
) -> bool:
if (
public_key == null
or signature.is_empty()
or signature.size() > MAX_SIGNATURE_BYTES
):
return false
var bytes: PackedByteArray = canonical_bytes_for_prefix(
LEGACY_DOMAIN_PREFIX,
domain,
fields,
)
if bytes.is_empty():
return false
var context := HashingContext.new()
if context.start(HashingContext.HASH_SHA256) != OK:
return false
context.update(bytes)
return Crypto.new().verify(
HashingContext.HASH_SHA256,
context.finish(),
signature,
public_key,
)
static func load_public_key(public_pem: String) -> CryptoKey:
var normalized := normalize_public_pem(public_pem)
if normalized.is_empty() or normalized.to_utf8_buffer().size() > MAX_PUBLIC_KEY_BYTES:

View file

@ -21,6 +21,8 @@ var _future_version := false
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_profile_path = path
_data_root = data_root
_expected_hash = ""
_future_version = false
func _temp_path() -> String:

View file

@ -51,6 +51,17 @@ func setup(
_apply_local_voice_to_avatar()
func reload_persisted_profile() -> bool:
if (
not _preferences.load_or_create()
or not _appearance_store.load_preferences()
):
return false
_apply_to_avatar(1, _appearance_store.get_snapshot())
_apply_local_voice_to_avatar()
return true
func get_persisted_name() -> String:
return _preferences.display_name

View file

@ -10,11 +10,41 @@ const BOOTSTRAP_VERSION := 1
const MANIFEST_VERSION := 1
const LAYOUT_VERSION := 1
const MANIFEST_FILENAME := "straywild_data.json"
const LEGACY_MANIFEST_FILENAME := "netfishing_data.json"
const README_FILENAME := "README.txt"
const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR"
const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_DATA_DIR"
const LEGACY_ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
const LEGACY_CREATE_ENVIRONMENT_VARIABLE := "NETFISHING_CREATE_DATA_DIR"
const APPLICATION_ID := "straywild"
const LEGACY_APPLICATION_ID := "netfishing"
const APP_DATA_PORTABLE_PATH := "user://portable-data"
const LEGACY_USER_DIRECTORY_NAME := "NETFISHING"
const LEGACY_USER_FILES := [
"data_root_bootstrap.json",
"data_root_bootstrap.json.backup",
"network_discovery.cfg",
"player_settings.json",
"player_settings.json.backup",
"controller_mappings.json",
"controller_mappings.json.backup",
"keyboard_mouse_bindings.json",
"keyboard_mouse_bindings.json.backup",
"player_save.json",
"network_profile.json",
"player_appearance.json",
"saved_servers.json",
"known_players.json",
"player_relationships.json",
"server_trust.json",
"host_bans.json",
"player_identity.key",
"player_identity.pub",
"player_identity.json",
"host_identity.key",
"host_identity.pub",
"host_identity.json",
]
const PATH_ALLOWED: StringName = &"allowed"
const PATH_SOURCE_PROJECT: StringName = &"source_project"
const PATH_INSTALLATION: StringName = &"installation"
@ -37,23 +67,29 @@ var override_active := false
func resolve() -> bool:
prepare_legacy_user_data()
_load_bootstrap_identity()
var command_line: String = _command_line_override()
if not command_line.is_empty():
override_active = true
mode = Mode.COMMAND_LINE_OVERRIDE
return _activate_existing(command_line, "", false)
if OS.has_environment(ENVIRONMENT_VARIABLE):
var environment_variable: String = _available_environment_variable(
ENVIRONMENT_VARIABLE,
LEGACY_ENVIRONMENT_VARIABLE,
)
if not environment_variable.is_empty():
override_active = true
mode = Mode.ENVIRONMENT_OVERRIDE
var environment_path: String = OS.get_environment(ENVIRONMENT_VARIABLE)
var environment_path: String = OS.get_environment(environment_variable)
if not environment_path.is_absolute_path():
return _fail("straywild_DATA_DIR must be an absolute path.")
if (
OS.get_environment(CREATE_ENVIRONMENT_VARIABLE) == "1"
and not FileAccess.file_exists(
environment_path.path_join(MANIFEST_FILENAME)
_environment_flag(
CREATE_ENVIRONMENT_VARIABLE,
LEGACY_CREATE_ENVIRONMENT_VARIABLE,
)
and not has_data_manifest(environment_path)
):
var created: Dictionary = create_unbound_root(environment_path)
if not bool(created.get("ok", false)):
@ -80,6 +116,30 @@ func resolve() -> bool:
return false
func prepare_legacy_user_data() -> void:
var current_root: String = _normalize(
ProjectSettings.globalize_path("user://")
)
if current_root.is_empty():
return
var legacy_root: String = _normalize(
current_root.get_base_dir().path_join(LEGACY_USER_DIRECTORY_NAME)
)
if legacy_root == current_root or not DirAccess.dir_exists_absolute(legacy_root):
return
for filename: String in LEGACY_USER_FILES:
var destination: String = current_root.path_join(filename)
if FileAccess.file_exists(destination):
continue
var source: String = legacy_root.path_join(filename)
if FileAccess.file_exists(source):
_copy_legacy_user_file(source, destination)
func has_data_manifest(path: String) -> bool:
return not _existing_manifest_path(_normalize(path)).is_empty()
func default_visible_path() -> String:
var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
if documents.is_empty():
@ -105,12 +165,14 @@ func select_new_root(path: String, app_data: bool = false) -> bool:
normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
if not _validate_candidate(normalized, true):
return false
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest_path: String = _existing_manifest_path(normalized)
if not manifest_path.is_empty():
var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The selected folder has a malformed straywild manifest.")
root_id = str(manifest["root_id"])
if not _upgrade_legacy_manifest(normalized, manifest, manifest_path):
return _fail("The legacy data-folder manifest could not be upgraded.")
else:
if not _directory_is_empty(normalized) and not app_data:
return _fail(
@ -145,14 +207,14 @@ func create_app_data_layout_for_migration(path: String) -> Dictionary:
var normalized: String = _normalize(path)
if not _validate_candidate(normalized, false):
return {"ok": false, "message": error_message}
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest_path: String = _existing_manifest_path(normalized)
if not manifest_path.is_empty():
var manifest: Dictionary = _read_json(manifest_path)
return (
{"ok": true, "root_id": str(manifest.get("root_id", ""))}
if _valid_manifest(manifest)
else {"ok": false, "message": "The app-data manifest is malformed."}
)
if not _valid_manifest(manifest):
return {"ok": false, "message": "The app-data manifest is malformed."}
if not _upgrade_legacy_manifest(normalized, manifest, manifest_path):
return {"ok": false, "message": "The app-data manifest could not be upgraded."}
return {"ok": true, "root_id": str(manifest.get("root_id", ""))}
var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id):
return {"ok": false, "message": "The app-data layout could not be created."}
@ -236,12 +298,14 @@ func _activate_existing(path: String, expected_id: String, permit_creation: bool
var normalized: String = _normalize(path)
if not _validate_candidate(normalized, permit_creation):
return false
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest_path):
var manifest_path: String = _existing_manifest_path(normalized)
if manifest_path.is_empty():
return _fail("The selected folder is not a straywild data folder.")
var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The straywild data-folder manifest is malformed.")
if not _upgrade_legacy_manifest(normalized, manifest, manifest_path):
return _fail("The legacy data-folder manifest could not be upgraded.")
var found_id: String = str(manifest["root_id"])
if not expected_id.is_empty() and expected_id != found_id:
return _fail("The selected data folder does not match this device pointer.")
@ -422,12 +486,71 @@ func _valid_manifest(data: Dictionary) -> bool:
return (
data.get("format_version") == MANIFEST_VERSION
and data.get("layout_version") == LAYOUT_VERSION
and data.get("application") == APPLICATION_ID
and data.get("application") in [APPLICATION_ID, LEGACY_APPLICATION_ID]
and typeof(data.get("root_id")) == TYPE_STRING
and str(data["root_id"]).length() == 32
)
func _existing_manifest_path(path: String) -> String:
var current: String = path.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(current):
return current
var legacy: String = path.path_join(LEGACY_MANIFEST_FILENAME)
return legacy if FileAccess.file_exists(legacy) else ""
func _upgrade_legacy_manifest(
path: String,
manifest: Dictionary,
manifest_path: String,
) -> bool:
if (
manifest_path.get_file() == MANIFEST_FILENAME
and manifest.get("application") == APPLICATION_ID
):
return true
var upgraded: Dictionary = manifest.duplicate(true)
upgraded["application"] = APPLICATION_ID
upgraded["last_opened_at_unix"] = int(Time.get_unix_time_from_system())
return _write_text_atomic(
path.path_join(MANIFEST_FILENAME),
JSON.stringify(upgraded, "\t"),
)
func _available_environment_variable(primary: String, legacy: String) -> String:
if OS.has_environment(primary):
return primary
return legacy if OS.has_environment(legacy) else ""
func _environment_flag(primary: String, legacy: String) -> bool:
var variable: String = _available_environment_variable(primary, legacy)
return not variable.is_empty() and OS.get_environment(variable) == "1"
func _copy_legacy_user_file(source: String, destination: String) -> bool:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
source,
16 * 1024 * 1024,
)
if bytes.is_empty() and FileAccess.get_open_error() != OK:
return false
if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK:
return false
var file: FileAccess = FileAccess.open(destination, FileAccess.WRITE)
if file == null:
return false
file.store_buffer(bytes)
file.flush()
var copied: bool = file.get_error() == OK
file.close()
if copied and source.ends_with(".key"):
FileAccess.set_unix_permissions(destination, 384)
return copied
func _directory_is_empty(path: String) -> bool:
var access: DirAccess = DirAccess.open(path)
if access == null:

View file

@ -21,6 +21,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
_records.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func is_muted(fingerprint: String) -> bool:

View file

@ -26,10 +26,7 @@ static func migrate_legacy_to(
) -> Dictionary:
var normalized: String = destination.simplify_path().trim_suffix("/")
if DirAccess.dir_exists_absolute(normalized):
var existing_manifest: String = normalized.path_join(
PlayerDataRoot.MANIFEST_FILENAME
)
if FileAccess.file_exists(existing_manifest):
if data_root.has_data_manifest(normalized):
return {
"ok": false,
"requires_existing_root_decision": true,
@ -106,7 +103,7 @@ static func migrate_active_to(
"message": "This data folder is already active.",
}
if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized):
if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
if data_root.has_data_manifest(normalized):
return {
"ok": false,
"requires_existing_root_decision": true,
@ -207,8 +204,7 @@ static func replace_existing_with_legacy(
destination: String,
) -> Dictionary:
var normalized: String = destination.simplify_path().trim_suffix("/")
var manifest: String = normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest):
if not data_root.has_data_manifest(normalized):
return {"ok": false, "message": "The selected folder is not a straywild data root."}
var recovery: String = ProjectSettings.globalize_path(
"user://migration-recovery"
@ -229,7 +225,7 @@ static func replace_existing_with_active(
destination: String,
) -> Dictionary:
var normalized: String = destination.simplify_path().trim_suffix("/")
if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
if not data_root.has_data_manifest(normalized):
return {"ok": false, "message": "The selected folder is not a straywild data root."}
var recovery: String = ProjectSettings.globalize_path(
"user://migration-recovery"

View file

@ -22,6 +22,12 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
_saved_entries.clear()
_recent_entries.clear()
_loaded = false
_recovery_warning = ""
_write_blocked = false
_expected_hash = ""
func _temp_path() -> String:

View file

@ -19,6 +19,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
_records.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification:

View file

@ -13,6 +13,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_profile_path = path
_data_root = data_root
_snapshot = CharacterCustomizationCatalog.default_snapshot()
_loaded = false
_future_version = false
_expected_hash = ""
func _temp_path() -> String:

View file

@ -2,6 +2,7 @@ class_name ProgressionSaveCodec
extends RefCounted
const MAGIC := "straywild_PROGRESSION_CONTAINER"
const LEGACY_MAGIC := "NETFISHING_PROGRESSION_CONTAINER"
const FORMAT_VERSION := 1
const LOCAL_KIND := "local_save"
const ARCHIVE_KIND := "portable_archive"
@ -9,8 +10,10 @@ const MAX_CONTAINER_BYTES := 16 * 1024 * 1024
# This key keeps routine progression files opaque to casual editing. Since the
# client is open source, it is intentionally not treated as a security secret.
# Its original value is a stable file-format detail so released saves remain
# readable after the Straywild rebrand.
const CONTAINER_PASSPHRASE := (
"straywild progression container v1 / not an identity credential"
"NETfishing progression container v1 / not an identity credential"
)
@ -102,15 +105,9 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary:
or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
):
return {"ok": false, "message": "progression file is unavailable."}
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path,
FileAccess.READ,
CONTAINER_PASSPHRASE,
)
if file == null:
var envelope_text: String = _read_encrypted_text(path)
if envelope_text.is_empty():
return {"ok": false, "message": "progression file could not be opened."}
var envelope_text: String = file.get_as_text()
file.close()
var envelope_json := JSON.new()
if (
envelope_json.parse(envelope_text) != OK
@ -118,8 +115,9 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary:
):
return {"ok": false, "message": "progression container is malformed."}
var envelope: Dictionary = envelope_json.data
var legacy_container: bool = envelope.get("magic") == LEGACY_MAGIC
if (
envelope.get("magic") != MAGIC
envelope.get("magic") not in [MAGIC, LEGACY_MAGIC]
or int(envelope.get("format_version", -1)) != FORMAT_VERSION
or str(envelope.get("kind", "")) != expected_kind
):
@ -144,9 +142,23 @@ static func _read_container(path: String, expected_kind: String) -> Dictionary:
"game_version": str(envelope.get("game_version", "")),
"created_at_unix": int(envelope.get("created_at_unix", 0)),
"legacy_plaintext": false,
"legacy_container": legacy_container,
}
static func _read_encrypted_text(path: String) -> String:
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path,
FileAccess.READ,
CONTAINER_PASSPHRASE,
)
if file == null:
return ""
var text: String = file.get_as_text()
file.close()
return text
static func _read_plaintext_dictionary(path: String) -> Dictionary:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
path,

View file

@ -9,7 +9,7 @@ readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.18.1-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.18.1-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}"

View file

@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" <<EOF
straywild code is licensed under GPL-3.0-or-later.
Source repository: https://forge.makearmy.io/woofmeow/straywild
Source repository: https://forge.makearmy.io/woofmeow/netfishing
Exact source revision: ${TAG_COMMIT}
Release tag: ${RELEASE_TAG}
PortMaster packaging revision: ${HEAD_COMMIT}
@ -262,6 +262,10 @@ grep -Fq 'CONTROLLER_MAPPING_FILE="$CONFDIR/cache/controller_mapping.txt"' \
"${STAGE_ROOT}/straywild.sh"
grep -Fq 'SAVEDIR="$PORTS_ROOT/saves/straywild"' \
"${STAGE_ROOT}/straywild.sh"
grep -Fq 'LEGACY_SAVEDIR="$PORTS_ROOT/saves/netfishing"' \
"${STAGE_ROOT}/straywild.sh"
grep -Fq 'cp -a "$LEGACY_CONFDIR/." "$CONFDIR/"' \
"${STAGE_ROOT}/straywild.sh"
grep -Fq '"straywild_DATA_DIR=$SAVEDIR"' \
"${STAGE_ROOT}/straywild.sh"
grep -Fq '"straywild_CREATE_DATA_DIR=1"' \

View file

@ -21,7 +21,11 @@ GAMEDIR="/${directory}/ports/straywild"
CONFDIR="$GAMEDIR/conf"
PORTS_ROOT="${GAMEDIR%/straywild}"
SAVEDIR="$PORTS_ROOT/saves/straywild"
LEGACY_GAMEDIR="$PORTS_ROOT/netfishing"
LEGACY_CONFDIR="$LEGACY_GAMEDIR/conf"
LEGACY_SAVEDIR="$PORTS_ROOT/saves/netfishing"
DATA_BOOTSTRAP_DIR="$CONFDIR/data/godot/app_userdata/straywild"
LEGACY_DATA_BOOTSTRAP_DIR="$CONFDIR/data/godot/app_userdata/NETFISHING"
GAME_EXECUTABLE="$GAMEDIR/straywild.aarch64"
GAME_LAUNCHER="$GAMEDIR/launch-straywild.sh"
GPTOKEYB_CONFIG="$GAMEDIR/straywild.gptk"
@ -33,13 +37,30 @@ HARBOURMASTER="$controlfolder/harbourmaster"
mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR"
chmod +x "$GAME_EXECUTABLE"
# A renamed PortMaster entry installs beside the former NETfishing port. Copy
# its device-local configuration once and continue using its established save
# root when present. New installations use straywild paths exclusively.
if [ -d "$LEGACY_CONFDIR" ] && [ ! -f "$CONFDIR/.netfishing-imported" ]; then
cp -a "$LEGACY_CONFDIR/." "$CONFDIR/"
touch "$CONFDIR/.netfishing-imported"
fi
if [ ! -f "$SAVEDIR/straywild_data.json" ] \
&& [ ! -f "$SAVEDIR/netfishing_data.json" ] \
&& { [ -f "$LEGACY_SAVEDIR/straywild_data.json" ] \
|| [ -f "$LEGACY_SAVEDIR/netfishing_data.json" ]; }; then
SAVEDIR="$LEGACY_SAVEDIR"
fi
# straywild uses a predictable save directory beside the port instead of
# presenting a folder picker on a small screen. Preserve an established
# data-root choice when upgrading an existing installation.
straywild_DATA_ENVIRONMENT=()
if [ -f "$SAVEDIR/straywild_data.json" ] || {
if [ -f "$SAVEDIR/straywild_data.json" ] \
|| [ -f "$SAVEDIR/netfishing_data.json" ] || {
[ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json" ] &&
[ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json.backup" ]
[ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json.backup" ] &&
[ ! -f "$LEGACY_DATA_BOOTSTRAP_DIR/data_root_bootstrap.json" ] &&
[ ! -f "$LEGACY_DATA_BOOTSTRAP_DIR/data_root_bootstrap.json.backup" ]
}; then
mkdir -p "$SAVEDIR"
straywild_DATA_ENVIRONMENT+=(

View file

@ -31,8 +31,10 @@ static func from_runtime() -> DedicatedServerConfig:
result.discovery_url = str(ProjectSettings.get_setting(
"network/discovery/base_url", ""
)).strip_edges()
var config_path: String = OS.get_environment(
"straywild_SERVER_CONFIG"
var config_path: String = _environment_string(
"straywild_SERVER_CONFIG",
"NETFISHING_SERVER_CONFIG",
"",
).strip_edges()
for argument: String in OS.get_cmdline_user_args():
if argument.begins_with("--config="):
@ -87,36 +89,44 @@ func _load_file(path: String) -> bool:
func _apply_environment() -> void:
server_name = _environment_string(
"straywild_SERVER_NAME", server_name
"straywild_SERVER_NAME", "NETFISHING_SERVER_NAME", server_name
)
bind_address = _environment_string(
"straywild_SERVER_BIND", bind_address
"straywild_SERVER_BIND", "NETFISHING_SERVER_BIND", bind_address
)
port = _environment_int(
"straywild_SERVER_PORT", "NETFISHING_SERVER_PORT", port
)
port = _environment_int("straywild_SERVER_PORT", port)
max_players = _environment_int(
"straywild_SERVER_MAX_PLAYERS", max_players
"straywild_SERVER_MAX_PLAYERS",
"NETFISHING_SERVER_MAX_PLAYERS",
max_players,
)
world_seed = _environment_int(
"straywild_WORLD_SEED", world_seed
"straywild_WORLD_SEED", "NETFISHING_WORLD_SEED", world_seed
)
public_listing = _environment_bool(
"straywild_SERVER_PUBLIC", public_listing
"straywild_SERVER_PUBLIC", "NETFISHING_SERVER_PUBLIC", public_listing
)
discovery_url = _environment_string(
"straywild_DISCOVERY_URL", discovery_url
"straywild_DISCOVERY_URL", "NETFISHING_DISCOVERY_URL", discovery_url
)
data_directory = _environment_string(
"straywild_DATA_DIR", data_directory
"straywild_DATA_DIR", "NETFISHING_DATA_DIR", data_directory
)
chat_logging = _environment_bool(
"straywild_CHAT_LOGGING", chat_logging
"straywild_CHAT_LOGGING", "NETFISHING_CHAT_LOGGING", chat_logging
)
chat_log_path = _environment_string(
"straywild_CHAT_LOG_PATH", chat_log_path
"straywild_CHAT_LOG_PATH", "NETFISHING_CHAT_LOG_PATH", chat_log_path
)
if OS.has_environment("straywild_SERVER_OPERATORS"):
var operators_variable: String = _available_environment_variable(
"straywild_SERVER_OPERATORS",
"NETFISHING_SERVER_OPERATORS",
)
if not operators_variable.is_empty():
operator_fingerprints = _parse_fingerprint_list(
OS.get_environment("straywild_SERVER_OPERATORS")
OS.get_environment(operators_variable)
)
@ -211,21 +221,45 @@ static func _safe_text(value: String) -> bool:
return true
static func _environment_string(name: String, fallback: String) -> String:
return OS.get_environment(name) if OS.has_environment(name) else fallback
static func _available_environment_variable(
name: String,
legacy_name: String,
) -> String:
if OS.has_environment(name):
return name
return legacy_name if OS.has_environment(legacy_name) else ""
static func _environment_int(name: String, fallback: int) -> int:
static func _environment_string(
name: String,
legacy_name: String,
fallback: String,
) -> String:
var variable: String = _available_environment_variable(name, legacy_name)
return OS.get_environment(variable) if not variable.is_empty() else fallback
static func _environment_int(
name: String,
legacy_name: String,
fallback: int,
) -> int:
var variable: String = _available_environment_variable(name, legacy_name)
return (
_parse_int(OS.get_environment(name), fallback)
if OS.has_environment(name) else fallback
_parse_int(OS.get_environment(variable), fallback)
if not variable.is_empty() else fallback
)
static func _environment_bool(name: String, fallback: bool) -> bool:
if not OS.has_environment(name):
static func _environment_bool(
name: String,
legacy_name: String,
fallback: bool,
) -> bool:
var variable: String = _available_environment_variable(name, legacy_name)
if variable.is_empty():
return fallback
var value: String = OS.get_environment(name).strip_edges().to_lower()
var value: String = OS.get_environment(variable).strip_edges().to_lower()
return value in ["1", "true", "yes", "on"]

View file

@ -39,6 +39,15 @@ func _run() -> void:
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 10)
var legacy_container_path: String = data_root.progression_backup_directory().path_join(
"netfishing-container.nfsave"
)
_assert_legacy_progression_container(
legacy_container_path,
decoded["data"] as Dictionary,
)
_assert_legacy_data_manifest(data_root.root_path.get_base_dir())
_assert_legacy_identity_backup(main, data_root)
var archive_path: String = data_root.progression_backup_directory().path_join(
"progression-test.nfsave"
@ -135,6 +144,9 @@ func _run() -> void:
assert(settings_panel.get_node_or_null("%ExportProgression") == null)
assert(settings_panel.get_node_or_null("%ImportProgression") == null)
var settings_panel: SettingsPanel = settings_panels.front() as SettingsPanel
settings_panel.set(
"_presentation_mode", SettingsPanel.PresentationMode.TITLE_EMBEDDED
)
var unchanged_root: String = data_root.root_path
settings_panel.call("_choose_data_folder")
var data_folder_dialog := (
@ -169,11 +181,14 @@ func _run() -> void:
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data"
)
var data_migration: Dictionary = PortableDataMigration.migrate_active_to(
data_root, migrated_root
settings_panel.call("_change_data_folder", migrated_root)
for _frame: int in 4:
await process_frame
assert(data_root.root_path == migrated_root)
assert(
(settings_panel.get_node("%SettingsFeedback") as Label).text
== "data folder changed."
)
assert(bool(data_migration.get("ok", false)))
assert(bool(data_migration.get("changed", false)))
var migrated_save: String = migrated_root.path_join(
"player/player_save.nfsave"
)
@ -193,6 +208,18 @@ func _run() -> void:
"ok", false
)
))
var live_slots: Array[Dictionary] = save_slots.list_slots()
assert(live_slots.size() == 2)
var live_created: Dictionary = save_slots.create_empty_slot(
"live root switch"
)
assert(bool(live_created.get("ok", false)))
var live_slot_id: String = str(live_created.get("slot_id", ""))
assert(save_slots.rename_slot(live_slot_id, "live root ready"))
assert(
str(save_slots.get_slot(live_slot_id).get("display_name", ""))
== "live root ready"
)
main.queue_free()
for _frame: int in 4:
@ -202,6 +229,124 @@ func _run() -> void:
quit()
func _assert_legacy_progression_container(
path: String,
payload: Dictionary,
) -> void:
var payload_json: String = JSON.stringify(payload)
var envelope: Dictionary = {
"magic": ProgressionSaveCodec.LEGACY_MAGIC,
"format_version": ProgressionSaveCodec.FORMAT_VERSION,
"kind": ProgressionSaveCodec.LOCAL_KIND,
"game_version": "0.18.1-alpha",
"created_at_unix": 1,
"payload_sha256": PortableFileGuard.hash_bytes(
payload_json.to_utf8_buffer()
),
"payload_json": payload_json,
}
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path,
FileAccess.WRITE,
ProgressionSaveCodec.CONTAINER_PASSPHRASE,
)
assert(file != null)
file.store_string(JSON.stringify(envelope))
file.close()
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(path)
assert(bool(decoded.get("ok", false)))
assert(bool(decoded.get("legacy_container", false)))
func _assert_legacy_data_manifest(parent: String) -> void:
var legacy_root: String = parent.path_join("netfishing-manifest-root")
assert(DirAccess.make_dir_recursive_absolute(legacy_root) == OK)
var legacy_id: String = "0123456789abcdef0123456789abcdef"
var legacy_manifest: Dictionary = {
"format_version": PlayerDataRoot.MANIFEST_VERSION,
"layout_version": PlayerDataRoot.LAYOUT_VERSION,
"application": PlayerDataRoot.LEGACY_APPLICATION_ID,
"root_id": legacy_id,
"created_at_unix": 1,
"last_opened_at_unix": 1,
}
var legacy_path: String = legacy_root.path_join(
PlayerDataRoot.LEGACY_MANIFEST_FILENAME
)
var file := FileAccess.open(legacy_path, FileAccess.WRITE)
assert(file != null)
file.store_string(JSON.stringify(legacy_manifest, "\t"))
file.close()
var legacy_data_root := PlayerDataRoot.new()
root.add_child(legacy_data_root)
assert(legacy_data_root.use_existing_root(legacy_root))
assert(legacy_data_root.root_id == legacy_id)
assert(FileAccess.file_exists(legacy_path))
var canonical_path: String = legacy_root.path_join(
PlayerDataRoot.MANIFEST_FILENAME
)
assert(FileAccess.file_exists(canonical_path))
var parser := JSON.new()
assert(parser.parse(FileAccess.get_file_as_string(canonical_path)) == OK)
assert((parser.data as Dictionary).get("application") == PlayerDataRoot.APPLICATION_ID)
legacy_data_root.queue_free()
func _assert_legacy_identity_backup(main: Node, data_root: PlayerDataRoot) -> void:
var player_identity := main.get("_player_identity") as PlayerIdentityStore
var identity_backups := main.get("_identity_backups") as IdentityBackupService
assert(player_identity != null and player_identity.is_ready())
assert(identity_backups != null)
var material: Dictionary = player_identity.export_identity_material()
var fingerprint: String = str(material.get("fingerprint", ""))
var private_key := CryptoKey.new()
assert(private_key.load_from_string(str(material.get("private_pem", ""))) == OK)
var canonical: PackedByteArray = NetworkIdentityCrypto.canonical_bytes_for_prefix(
NetworkIdentityCrypto.LEGACY_DOMAIN_PREFIX,
"identity_backup_self_test",
["player", fingerprint],
)
var context := HashingContext.new()
assert(context.start(HashingContext.HASH_SHA256) == OK)
context.update(canonical)
var signature: PackedByteArray = Crypto.new().sign(
HashingContext.HASH_SHA256,
context.finish(),
private_key,
)
var envelope: Dictionary = {
"magic": IdentityBackupService.LEGACY_MAGIC,
"format_version": IdentityBackupService.FORMAT_VERSION,
"identity_type": "player",
"algorithm": NetworkIdentityCrypto.ALGORITHM,
"private_pem": material.get("private_pem", ""),
"public_pem": material.get("public_pem", ""),
"fingerprint": fingerprint,
"created_at_unix": 1,
"source_device_id": data_root.device_id,
"self_signature": Marshalls.raw_to_base64(signature),
}
var backup_path: String = data_root.identity_backup_directory().path_join(
"netfishing-player.nfidentity"
)
var passphrase := "legacy-test-passphrase"
var file: FileAccess = FileAccess.open_encrypted_with_pass(
backup_path,
FileAccess.WRITE,
passphrase,
)
assert(file != null)
file.store_string(JSON.stringify(envelope))
file.close()
var inspected: Dictionary = identity_backups.inspect_backup(
backup_path,
passphrase,
"player",
)
assert(bool(inspected.get("ok", false)))
assert(str(inspected.get("fingerprint", "")) == fingerprint)
func _assert_opaque(path: String) -> void:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
assert(not bytes.is_empty())

View file

@ -81,6 +81,7 @@ signal shop_backdrop_visibility_changed(is_visible: bool)
signal virtual_pointer_mode_changed(is_active: bool)
signal social_prompt_accepted
signal social_prompt_declined
signal data_root_changed
const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu"
@ -271,6 +272,8 @@ func _ready() -> void:
_pause_settings_panel.crisp_reset_focus_requested.connect(
crisp_reset_focus_requested.emit
)
_title_settings_panel.data_root_changed.connect(data_root_changed.emit)
_pause_settings_panel.data_root_changed.connect(data_root_changed.emit)
if not Input.joy_connection_changed.is_connected(
_on_controller_connection_changed
):

View file

@ -40,6 +40,7 @@ signal opened
signal crisp_reset_focus_requested
signal panel_visibility_changed(is_visible: bool)
signal navigation_transition_started
signal data_root_changed
enum PresentationMode {
TITLE_EMBEDDED,
@ -612,6 +613,7 @@ func _refresh_data_page() -> void:
%DataStorageMode.text = "storage mode: " + _data_root.storage_mode_text()
%ChangeDataFolder.disabled = (
_data_root.override_active
or _presentation_mode != PresentationMode.TITLE_EMBEDDED
or (_network_session != null and _network_session.is_session_active())
)
var fingerprint: String = (
@ -668,6 +670,9 @@ func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active:
_feedback.text = "the data folder is externally managed."
return
if _presentation_mode != PresentationMode.TITLE_EMBEDDED:
_feedback.text = "return to title to change the data folder."
return
if _network_session != null and _network_session.is_session_active():
_feedback.text = "return to title to change the data folder."
return
@ -684,6 +689,12 @@ func _choose_data_folder() -> void:
func _change_data_folder(path: String) -> void:
if (
_presentation_mode != PresentationMode.TITLE_EMBEDDED
or (_network_session != null and _network_session.is_session_active())
):
_feedback.text = "return to title to change the data folder."
return
var result: Dictionary = PortableDataMigration.migrate_active_to(
_data_root, path
)
@ -697,9 +708,7 @@ func _change_data_folder(path: String) -> void:
).to_lower()
_refresh_data_page()
return
_feedback.text = "data folder changed. straywild will close safely."
_refresh_data_page()
get_tree().call_deferred("quit")
_complete_data_root_change("data folder changed.")
else:
_feedback.text = str(
result.get("message", "could not change the data folder.")
@ -717,8 +726,7 @@ func _show_existing_data_folder_choice(path: String) -> void:
dialog.add_button("replace selected data", false, "replace")
dialog.confirmed.connect(func() -> void:
if _data_root.use_existing_root(path):
_feedback.text = "data folder changed. straywild will close safely."
get_tree().call_deferred("quit")
_complete_data_root_change("data folder changed.")
else:
_feedback.text = _data_root.error_message
dialog.queue_free()
@ -733,7 +741,7 @@ func _show_existing_data_folder_choice(path: String) -> void:
result.get("message", "could not replace selected data.")
)
if bool(result.get("ok", false)):
get_tree().call_deferred("quit")
_complete_data_root_change(_feedback.text.to_lower())
dialog.queue_free()
)
_interface_fonts.apply_utility_theme(dialog)
@ -744,6 +752,12 @@ func _show_existing_data_folder_choice(path: String) -> void:
)
func _complete_data_root_change(message: String) -> void:
_feedback.text = message
_refresh_data_page()
data_root_changed.emit()
func _choose_identity_export(identity_type: String) -> void:
if not _identity_operation_allowed():
return

View file

@ -243,6 +243,11 @@ func reopen_to_menu() -> void:
_set_title_bubbles_interactive(true)
func refresh_after_data_root_change() -> void:
_save_slots_page.refresh_page()
_refresh_save_inspection()
func open_join_game_page(endpoint: String = "") -> void:
_cancel_title_entry_transition()
_awaiting_start_input = false