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 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 ## 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 upgraded. Device-local data, configuration, and cache remain under
`straywild/conf/data`, `straywild/conf/config`, and `straywild/conf/data`, `straywild/conf/config`, and
`straywild/conf/cache`. `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, - The archive must not contain `conf/`, saves, identities, logs, source files,
`.git`, or `.godot` content. `.git`, or `.godot` content.
- Release downloads must publish only the canonical `straywild.zip` for this - 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 --offline --no-check install ./straywild.zip
``` ```
Before upgrading an existing installation, preserve Before upgrading an existing installation, preserve either the current
`/mnt/mmc/ports/straywild/conf/` and `/mnt/mmc/ports/saves/straywild/` on the `/mnt/mmc/ports/straywild/` paths or the former `/mnt/mmc/ports/netfishing/`
device. After installation: paths on the device. After installation:
1. Confirm the installed executable and PCK hashes match the staged release. 1. Confirm the installed executable and PCK hashes match the staged release.
2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to 2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to

View file

@ -17,10 +17,10 @@ for local README files.
## Related repositories ## 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 stays with the service implementation and defines its versioned HTTP
contract. 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 stays with its installer and update scripts. It consumes this repository's
attribution and legal notices from the exact pinned game commit instead of attribution and legal notices from the exact pinned game commit instead of
maintaining copies. maintaining copies.

View file

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

View file

@ -277,6 +277,7 @@ var _rain_ambience: RainAmbienceType
func _ready() -> void: func _ready() -> void:
_data_root.prepare_legacy_user_data()
_performance_profile = RuntimePerformanceProfileType.from_environment() _performance_profile = RuntimePerformanceProfileType.from_environment()
_dedicated_runtime = _is_dedicated_server_runtime() _dedicated_runtime = _is_dedicated_server_runtime()
if _dedicated_runtime: if _dedicated_runtime:
@ -471,10 +472,7 @@ func _start_dedicated_server() -> void:
_fail_dedicated_server(config.error_message) _fail_dedicated_server(config.error_message)
return return
var data_path: String = config.data_directory var data_path: String = config.data_directory
var manifest_path: String = data_path.path_join( if not _data_root.has_data_manifest(data_path):
PlayerDataRoot.MANIFEST_FILENAME
)
if not FileAccess.file_exists(manifest_path):
var created: Dictionary = _data_root.create_unbound_root(data_path) var created: Dictionary = _data_root.create_unbound_root(data_path)
if not bool(created.get("ok", false)): if not bool(created.get("ok", false)):
_fail_dedicated_server(str(created.get( _fail_dedicated_server(str(created.get(
@ -973,6 +971,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_session, _network_session,
_interface_fonts, _interface_fonts,
) )
_game_ui.data_root_changed.connect(_on_data_root_changed)
_game_ui.setup_controller_mapping(_controller_mapping_manager) _game_ui.setup_controller_mapping(_controller_mapping_manager)
_ui_pixelation.setup_controller_mapping(_controller_mapping_manager) _ui_pixelation.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_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()) _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( func _on_server_trust_required(
endpoint: String, endpoint: String,
expected_fingerprint: String, expected_fingerprint: String,

View file

@ -5,6 +5,10 @@ const PROFILE_ENVIRONMENT_VARIABLE: String = (
"straywild_PERFORMANCE_PROFILE" "straywild_PERFORMANCE_PROFILE"
) )
const LEGACY_LIGHT_ENVIRONMENT_VARIABLE: String = "straywild_LOW_END" 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 NORMAL_PROFILE: StringName = &"normal"
const LIGHT_PROFILE: StringName = &"light" const LIGHT_PROFILE: StringName = &"light"
const NORMAL_WORLD_RENDER_SCALE: float = 1.0 const NORMAL_WORLD_RENDER_SCALE: float = 1.0
@ -14,9 +18,19 @@ var _profile_name: StringName = NORMAL_PROFILE
static func from_environment() -> RuntimePerformanceProfile: 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( return from_name(
StringName(OS.get_environment(PROFILE_ENVIRONMENT_VARIABLE)), StringName(profile_name),
OS.get_environment(LEGACY_LIGHT_ENVIRONMENT_VARIABLE) == "1", (
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_SETTING: String = "network/discovery/base_url"
const BASE_URL_ENVIRONMENT: String = "straywild_DISCOVERY_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 SETTINGS_PATH: String = "user://network_discovery.cfg"
const DEFAULT_ROOM_NAME: String = "Player's Server" const DEFAULT_ROOM_NAME: String = "Player's Server"
const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room" const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room"
@ -553,6 +554,10 @@ func is_own_room(room: Dictionary) -> bool:
func _configured_base_url() -> String: func _configured_base_url() -> String:
var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges() 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(): if value.is_empty():
value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges() value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges()
while value.ends_with("/"): while value.ends_with("/"):

View file

@ -17,6 +17,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void: func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path _store_path = path
_data_root = data_root _data_root = data_root
_namespaces.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool: 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) signal operation_finished(success: bool, message: String)
const MAGIC := "straywild_IDENTITY_BACKUP" const MAGIC := "straywild_IDENTITY_BACKUP"
const LEGACY_MAGIC := "NETFISHING_IDENTITY_BACKUP"
const FORMAT_VERSION := 1 const FORMAT_VERSION := 1
const MAX_BACKUP_BYTES := 256 * 1024 const MAX_BACKUP_BYTES := 256 * 1024
const MIN_PASSPHRASE_LENGTH := 12 const MIN_PASSPHRASE_LENGTH := 12
@ -110,8 +111,9 @@ func inspect_backup(
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY: if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
return {"ok": false} return {"ok": false}
var data: Dictionary = json.data var data: Dictionary = json.data
var legacy_backup: bool = data.get("magic") == LEGACY_MAGIC
if ( if (
data.get("magic") != MAGIC data.get("magic") not in [MAGIC, LEGACY_MAGIC]
or data.get("format_version") != FORMAT_VERSION or data.get("format_version") != FORMAT_VERSION
or data.get("identity_type") != expected_type or data.get("identity_type") != expected_type
or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
@ -134,12 +136,23 @@ func inspect_backup(
var signature: PackedByteArray = Marshalls.base64_to_raw( var signature: PackedByteArray = Marshalls.base64_to_raw(
str(data.get("self_signature", "")) str(data.get("self_signature", ""))
) )
if not NetworkIdentityCrypto.verify_fields( var public_key: CryptoKey = NetworkIdentityCrypto.load_public_key(public_pem)
NetworkIdentityCrypto.load_public_key(public_pem), var backup_signature_valid: bool = (
"identity_backup_self_test", NetworkIdentityCrypto.verify_legacy_fields(
[expected_type, fingerprint], public_key,
signature, "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} return {"ok": false}
var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields( var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields(
key, "identity_import_self_test", [fingerprint] 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: func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path _store_path = path
_data_root = data_root _data_root = data_root
_records.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func observe(fingerprint: String, display_name: String) -> String: 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_PUBLIC_KEY_BYTES: int = 8192
const MAX_SIGNATURE_BYTES: int = 1024 const MAX_SIGNATURE_BYTES: int = 1024
const DOMAIN_PREFIX: String = "straywild" const DOMAIN_PREFIX: String = "straywild"
const LEGACY_DOMAIN_PREFIX: String = "NETFISHING"
const IDENTITY_VERSION: String = "identity_v1" 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: 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() var output := PackedByteArray()
_append_string(output, DOMAIN_PREFIX) _append_string(output, prefix)
_append_string(output, IDENTITY_VERSION) _append_string(output, IDENTITY_VERSION)
_append_string(output, domain) _append_string(output, domain)
for value: Variant in fields: 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: static func load_public_key(public_pem: String) -> CryptoKey:
var normalized := normalize_public_pem(public_pem) var normalized := normalize_public_pem(public_pem)
if normalized.is_empty() or normalized.to_utf8_buffer().size() > MAX_PUBLIC_KEY_BYTES: 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: func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_profile_path = path _profile_path = path
_data_root = data_root _data_root = data_root
_expected_hash = ""
_future_version = false
func _temp_path() -> String: func _temp_path() -> String:

View file

@ -51,6 +51,17 @@ func setup(
_apply_local_voice_to_avatar() _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: func get_persisted_name() -> String:
return _preferences.display_name return _preferences.display_name

View file

@ -10,11 +10,41 @@ const BOOTSTRAP_VERSION := 1
const MANIFEST_VERSION := 1 const MANIFEST_VERSION := 1
const LAYOUT_VERSION := 1 const LAYOUT_VERSION := 1
const MANIFEST_FILENAME := "straywild_data.json" const MANIFEST_FILENAME := "straywild_data.json"
const LEGACY_MANIFEST_FILENAME := "netfishing_data.json"
const README_FILENAME := "README.txt" const README_FILENAME := "README.txt"
const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR" const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR"
const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_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 APPLICATION_ID := "straywild"
const LEGACY_APPLICATION_ID := "netfishing"
const APP_DATA_PORTABLE_PATH := "user://portable-data" 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_ALLOWED: StringName = &"allowed"
const PATH_SOURCE_PROJECT: StringName = &"source_project" const PATH_SOURCE_PROJECT: StringName = &"source_project"
const PATH_INSTALLATION: StringName = &"installation" const PATH_INSTALLATION: StringName = &"installation"
@ -37,23 +67,29 @@ var override_active := false
func resolve() -> bool: func resolve() -> bool:
prepare_legacy_user_data()
_load_bootstrap_identity() _load_bootstrap_identity()
var command_line: String = _command_line_override() var command_line: String = _command_line_override()
if not command_line.is_empty(): if not command_line.is_empty():
override_active = true override_active = true
mode = Mode.COMMAND_LINE_OVERRIDE mode = Mode.COMMAND_LINE_OVERRIDE
return _activate_existing(command_line, "", false) 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 override_active = true
mode = Mode.ENVIRONMENT_OVERRIDE 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(): if not environment_path.is_absolute_path():
return _fail("straywild_DATA_DIR must be an absolute path.") return _fail("straywild_DATA_DIR must be an absolute path.")
if ( if (
OS.get_environment(CREATE_ENVIRONMENT_VARIABLE) == "1" _environment_flag(
and not FileAccess.file_exists( CREATE_ENVIRONMENT_VARIABLE,
environment_path.path_join(MANIFEST_FILENAME) LEGACY_CREATE_ENVIRONMENT_VARIABLE,
) )
and not has_data_manifest(environment_path)
): ):
var created: Dictionary = create_unbound_root(environment_path) var created: Dictionary = create_unbound_root(environment_path)
if not bool(created.get("ok", false)): if not bool(created.get("ok", false)):
@ -80,6 +116,30 @@ func resolve() -> bool:
return false 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: func default_visible_path() -> String:
var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
if documents.is_empty(): 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) normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
if not _validate_candidate(normalized, true): if not _validate_candidate(normalized, true):
return false return false
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) var manifest_path: String = _existing_manifest_path(normalized)
if FileAccess.file_exists(manifest_path): if not manifest_path.is_empty():
var manifest: Dictionary = _read_json(manifest_path) var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest): if not _valid_manifest(manifest):
return _fail("The selected folder has a malformed straywild manifest.") return _fail("The selected folder has a malformed straywild manifest.")
root_id = str(manifest["root_id"]) 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: else:
if not _directory_is_empty(normalized) and not app_data: if not _directory_is_empty(normalized) and not app_data:
return _fail( return _fail(
@ -145,14 +207,14 @@ func create_app_data_layout_for_migration(path: String) -> Dictionary:
var normalized: String = _normalize(path) var normalized: String = _normalize(path)
if not _validate_candidate(normalized, false): if not _validate_candidate(normalized, false):
return {"ok": false, "message": error_message} return {"ok": false, "message": error_message}
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) var manifest_path: String = _existing_manifest_path(normalized)
if FileAccess.file_exists(manifest_path): if not manifest_path.is_empty():
var manifest: Dictionary = _read_json(manifest_path) var manifest: Dictionary = _read_json(manifest_path)
return ( if not _valid_manifest(manifest):
{"ok": true, "root_id": str(manifest.get("root_id", ""))} return {"ok": false, "message": "The app-data manifest is malformed."}
if _valid_manifest(manifest) if not _upgrade_legacy_manifest(normalized, manifest, manifest_path):
else {"ok": false, "message": "The app-data manifest is malformed."} 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() var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id): if not _create_layout(normalized, id):
return {"ok": false, "message": "The app-data layout could not be created."} 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) var normalized: String = _normalize(path)
if not _validate_candidate(normalized, permit_creation): if not _validate_candidate(normalized, permit_creation):
return false return false
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME) var manifest_path: String = _existing_manifest_path(normalized)
if not FileAccess.file_exists(manifest_path): if manifest_path.is_empty():
return _fail("The selected folder is not a straywild data folder.") return _fail("The selected folder is not a straywild data folder.")
var manifest: Dictionary = _read_json(manifest_path) var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest): if not _valid_manifest(manifest):
return _fail("The straywild data-folder manifest is malformed.") 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"]) var found_id: String = str(manifest["root_id"])
if not expected_id.is_empty() and expected_id != found_id: if not expected_id.is_empty() and expected_id != found_id:
return _fail("The selected data folder does not match this device pointer.") return _fail("The selected data folder does not match this device pointer.")
@ -422,12 +486,71 @@ func _valid_manifest(data: Dictionary) -> bool:
return ( return (
data.get("format_version") == MANIFEST_VERSION data.get("format_version") == MANIFEST_VERSION
and data.get("layout_version") == LAYOUT_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 typeof(data.get("root_id")) == TYPE_STRING
and str(data["root_id"]).length() == 32 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: func _directory_is_empty(path: String) -> bool:
var access: DirAccess = DirAccess.open(path) var access: DirAccess = DirAccess.open(path)
if access == null: if access == null:

View file

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

View file

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

View file

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

View file

@ -19,6 +19,10 @@ var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void: func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path _store_path = path
_data_root = data_root _data_root = data_root
_records.clear()
_loaded = false
_write_blocked = false
_expected_hash = ""
func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification: 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: func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_profile_path = path _profile_path = path
_data_root = data_root _data_root = data_root
_snapshot = CharacterCustomizationCatalog.default_snapshot()
_loaded = false
_future_version = false
_expected_hash = ""
func _temp_path() -> String: func _temp_path() -> String:

View file

@ -2,6 +2,7 @@ class_name ProgressionSaveCodec
extends RefCounted extends RefCounted
const MAGIC := "straywild_PROGRESSION_CONTAINER" const MAGIC := "straywild_PROGRESSION_CONTAINER"
const LEGACY_MAGIC := "NETFISHING_PROGRESSION_CONTAINER"
const FORMAT_VERSION := 1 const FORMAT_VERSION := 1
const LOCAL_KIND := "local_save" const LOCAL_KIND := "local_save"
const ARCHIVE_KIND := "portable_archive" 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 # 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. # 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 := ( 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 or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
): ):
return {"ok": false, "message": "progression file is unavailable."} return {"ok": false, "message": "progression file is unavailable."}
var file: FileAccess = FileAccess.open_encrypted_with_pass( var envelope_text: String = _read_encrypted_text(path)
path, if envelope_text.is_empty():
FileAccess.READ,
CONTAINER_PASSPHRASE,
)
if file == null:
return {"ok": false, "message": "progression file could not be opened."} 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() var envelope_json := JSON.new()
if ( if (
envelope_json.parse(envelope_text) != OK 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."} return {"ok": false, "message": "progression container is malformed."}
var envelope: Dictionary = envelope_json.data var envelope: Dictionary = envelope_json.data
var legacy_container: bool = envelope.get("magic") == LEGACY_MAGIC
if ( if (
envelope.get("magic") != MAGIC envelope.get("magic") not in [MAGIC, LEGACY_MAGIC]
or int(envelope.get("format_version", -1)) != FORMAT_VERSION or int(envelope.get("format_version", -1)) != FORMAT_VERSION
or str(envelope.get("kind", "")) != expected_kind 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", "")), "game_version": str(envelope.get("game_version", "")),
"created_at_unix": int(envelope.get("created_at_unix", 0)), "created_at_unix": int(envelope.get("created_at_unix", 0)),
"legacy_plaintext": false, "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: static func _read_plaintext_dictionary(path: String) -> Dictionary:
var bytes: PackedByteArray = PortableFileGuard.read_bytes( var bytes: PackedByteArray = PortableFileGuard.read_bytes(
path, path,

View file

@ -9,7 +9,7 @@ readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" 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 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 LINUX_ZIP="${BUILD_ROOT}/straywild-v0.18.1-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" 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. 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} Exact source revision: ${TAG_COMMIT}
Release tag: ${RELEASE_TAG} Release tag: ${RELEASE_TAG}
PortMaster packaging revision: ${HEAD_COMMIT} PortMaster packaging revision: ${HEAD_COMMIT}
@ -262,6 +262,10 @@ grep -Fq 'CONTROLLER_MAPPING_FILE="$CONFDIR/cache/controller_mapping.txt"' \
"${STAGE_ROOT}/straywild.sh" "${STAGE_ROOT}/straywild.sh"
grep -Fq 'SAVEDIR="$PORTS_ROOT/saves/straywild"' \ grep -Fq 'SAVEDIR="$PORTS_ROOT/saves/straywild"' \
"${STAGE_ROOT}/straywild.sh" "${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"' \ grep -Fq '"straywild_DATA_DIR=$SAVEDIR"' \
"${STAGE_ROOT}/straywild.sh" "${STAGE_ROOT}/straywild.sh"
grep -Fq '"straywild_CREATE_DATA_DIR=1"' \ grep -Fq '"straywild_CREATE_DATA_DIR=1"' \

View file

@ -21,7 +21,11 @@ GAMEDIR="/${directory}/ports/straywild"
CONFDIR="$GAMEDIR/conf" CONFDIR="$GAMEDIR/conf"
PORTS_ROOT="${GAMEDIR%/straywild}" PORTS_ROOT="${GAMEDIR%/straywild}"
SAVEDIR="$PORTS_ROOT/saves/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" 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_EXECUTABLE="$GAMEDIR/straywild.aarch64"
GAME_LAUNCHER="$GAMEDIR/launch-straywild.sh" GAME_LAUNCHER="$GAMEDIR/launch-straywild.sh"
GPTOKEYB_CONFIG="$GAMEDIR/straywild.gptk" GPTOKEYB_CONFIG="$GAMEDIR/straywild.gptk"
@ -33,13 +37,30 @@ HARBOURMASTER="$controlfolder/harbourmaster"
mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR" mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR"
chmod +x "$GAME_EXECUTABLE" 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 # straywild uses a predictable save directory beside the port instead of
# presenting a folder picker on a small screen. Preserve an established # presenting a folder picker on a small screen. Preserve an established
# data-root choice when upgrading an existing installation. # data-root choice when upgrading an existing installation.
straywild_DATA_ENVIRONMENT=() 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" ] &&
[ ! -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 }; then
mkdir -p "$SAVEDIR" mkdir -p "$SAVEDIR"
straywild_DATA_ENVIRONMENT+=( straywild_DATA_ENVIRONMENT+=(

View file

@ -31,8 +31,10 @@ static func from_runtime() -> DedicatedServerConfig:
result.discovery_url = str(ProjectSettings.get_setting( result.discovery_url = str(ProjectSettings.get_setting(
"network/discovery/base_url", "" "network/discovery/base_url", ""
)).strip_edges() )).strip_edges()
var config_path: String = OS.get_environment( var config_path: String = _environment_string(
"straywild_SERVER_CONFIG" "straywild_SERVER_CONFIG",
"NETFISHING_SERVER_CONFIG",
"",
).strip_edges() ).strip_edges()
for argument: String in OS.get_cmdline_user_args(): for argument: String in OS.get_cmdline_user_args():
if argument.begins_with("--config="): if argument.begins_with("--config="):
@ -87,36 +89,44 @@ func _load_file(path: String) -> bool:
func _apply_environment() -> void: func _apply_environment() -> void:
server_name = _environment_string( server_name = _environment_string(
"straywild_SERVER_NAME", server_name "straywild_SERVER_NAME", "NETFISHING_SERVER_NAME", server_name
) )
bind_address = _environment_string( 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( max_players = _environment_int(
"straywild_SERVER_MAX_PLAYERS", max_players "straywild_SERVER_MAX_PLAYERS",
"NETFISHING_SERVER_MAX_PLAYERS",
max_players,
) )
world_seed = _environment_int( world_seed = _environment_int(
"straywild_WORLD_SEED", world_seed "straywild_WORLD_SEED", "NETFISHING_WORLD_SEED", world_seed
) )
public_listing = _environment_bool( public_listing = _environment_bool(
"straywild_SERVER_PUBLIC", public_listing "straywild_SERVER_PUBLIC", "NETFISHING_SERVER_PUBLIC", public_listing
) )
discovery_url = _environment_string( discovery_url = _environment_string(
"straywild_DISCOVERY_URL", discovery_url "straywild_DISCOVERY_URL", "NETFISHING_DISCOVERY_URL", discovery_url
) )
data_directory = _environment_string( data_directory = _environment_string(
"straywild_DATA_DIR", data_directory "straywild_DATA_DIR", "NETFISHING_DATA_DIR", data_directory
) )
chat_logging = _environment_bool( chat_logging = _environment_bool(
"straywild_CHAT_LOGGING", chat_logging "straywild_CHAT_LOGGING", "NETFISHING_CHAT_LOGGING", chat_logging
) )
chat_log_path = _environment_string( 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( 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 return true
static func _environment_string(name: String, fallback: String) -> String: static func _available_environment_variable(
return OS.get_environment(name) if OS.has_environment(name) else fallback 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 ( return (
_parse_int(OS.get_environment(name), fallback) _parse_int(OS.get_environment(variable), fallback)
if OS.has_environment(name) else fallback if not variable.is_empty() else fallback
) )
static func _environment_bool(name: String, fallback: bool) -> bool: static func _environment_bool(
if not OS.has_environment(name): name: String,
legacy_name: String,
fallback: bool,
) -> bool:
var variable: String = _available_environment_variable(name, legacy_name)
if variable.is_empty():
return fallback 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"] 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) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false))) assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 10) 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( var archive_path: String = data_root.progression_backup_directory().path_join(
"progression-test.nfsave" "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("%ExportProgression") == null)
assert(settings_panel.get_node_or_null("%ImportProgression") == null) assert(settings_panel.get_node_or_null("%ImportProgression") == null)
var settings_panel: SettingsPanel = settings_panels.front() as SettingsPanel 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 var unchanged_root: String = data_root.root_path
settings_panel.call("_choose_data_folder") settings_panel.call("_choose_data_folder")
var data_folder_dialog := ( var data_folder_dialog := (
@ -169,11 +181,14 @@ func _run() -> void:
var migrated_root: String = data_root.root_path.get_base_dir().path_join( var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data" "progression-migrated-data"
) )
var data_migration: Dictionary = PortableDataMigration.migrate_active_to( settings_panel.call("_change_data_folder", migrated_root)
data_root, 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( var migrated_save: String = migrated_root.path_join(
"player/player_save.nfsave" "player/player_save.nfsave"
) )
@ -193,6 +208,18 @@ func _run() -> void:
"ok", false "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() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
@ -202,6 +229,124 @@ func _run() -> void:
quit() 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: func _assert_opaque(path: String) -> void:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path) var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
assert(not bytes.is_empty()) 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 virtual_pointer_mode_changed(is_active: bool)
signal social_prompt_accepted signal social_prompt_accepted
signal social_prompt_declined signal social_prompt_declined
signal data_root_changed
const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu"
@ -271,6 +272,8 @@ func _ready() -> void:
_pause_settings_panel.crisp_reset_focus_requested.connect( _pause_settings_panel.crisp_reset_focus_requested.connect(
crisp_reset_focus_requested.emit 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( if not Input.joy_connection_changed.is_connected(
_on_controller_connection_changed _on_controller_connection_changed
): ):

View file

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

View file

@ -243,6 +243,11 @@ func reopen_to_menu() -> void:
_set_title_bubbles_interactive(true) _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: func open_join_game_page(endpoint: String = "") -> void:
_cancel_title_entry_transition() _cancel_title_entry_transition()
_awaiting_start_input = false _awaiting_start_input = false