diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/games/stalkeranomaly | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/games/stalkeranomaly')
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/XRIO.py | 148 | ||||
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/XRMath.py | 37 | ||||
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/XRNET.py | 45 | ||||
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/XRObject.py | 277 | ||||
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/XRSave.py | 164 | ||||
| -rw-r--r-- | libs/basic_games/games/stalkeranomaly/__init__.py | 38 |
6 files changed, 709 insertions, 0 deletions
diff --git a/libs/basic_games/games/stalkeranomaly/XRIO.py b/libs/basic_games/games/stalkeranomaly/XRIO.py new file mode 100644 index 0000000..870f28d --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRIO.py @@ -0,0 +1,148 @@ +# -*- encoding: utf-8 -*- +from __future__ import annotations + +import io +import struct +from typing import Optional, Tuple + +from .XRMath import IVec3 + + +class XRReader: + def __init__(self, buffer: bytes): + self._buffer = buffer + self._pos = 0 + + def __len__(self) -> int: + return len(self._buffer) + + def _read(self, size: int) -> Tuple[bytes, int]: + pos = min(len(self._buffer), self._pos + size) + buffer = self._buffer[self._pos : pos] + return (buffer, pos) + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, pos) = self._read(size) + self._pos = pos + return buffer + + def peek(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, _pos) = self._read(size) + return buffer + + def seek(self, pos: int, whence: int = io.SEEK_SET) -> int: + if whence == 0: + if pos < 0: + raise ValueError(f"negative seek position {pos}") + self._pos = pos + elif whence == 1: + self._pos = max(0, self._pos + pos) + elif whence == 2: + self._pos = max(0, len(self._buffer) + pos) + else: + raise ValueError("unsupported whence value") + return self._pos + + def elapsed(self) -> int: + return len(self._buffer) - self._pos + + def eof(self) -> bool: + return self.elapsed() <= 0 + + def u8(self) -> int: + return int(struct.unpack("<B", self.read(1))[0]) + + def s8(self) -> int: + return int(struct.unpack("<b", self.read(1))[0]) + + def u16(self) -> int: + return int(struct.unpack("<H", self.read(2))[0]) + + def s16(self) -> int: + return int(struct.unpack("<h", self.read(2))[0]) + + def u32(self) -> int: + return int(struct.unpack("<I", self.read(4))[0]) + + def s32(self) -> int: + return int(struct.unpack("<i", self.read(4))[0]) + + def u64(self) -> int: + return int(struct.unpack("<Q", self.read(8))[0]) + + def s64(self) -> int: + return int(struct.unpack("<q", self.read(8))[0]) + + def bool(self) -> bool: + return bool(struct.unpack("<?", self.read(1))[0]) + + def float(self) -> float: + return float(struct.unpack("<f", self.read(4))[0]) + + def str(self) -> str: + chars = bytearray() + while not self.eof(): + c = self.read(1) + if c == b"\x00": + return str(chars, "utf-8") + else: + chars += c + return "" + + def fvec3(self) -> IVec3: + (f1, f2, f3) = struct.unpack("<fff", self.read(12)) + return IVec3(f1, f2, f3) + + +class XRStream(XRReader): + def __init__(self, buffer: bytes): + super().__init__(buffer) + self.last_pos: int = 0 + + def find_chunk(self, id: int) -> Optional[int]: + dw_type = 0 + dw_size = 0 + success = False + if self.last_pos != 0: + self.seek(self.last_pos) + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + + if not success: + self.seek(0) + while not self.eof(): + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + break + else: + self.seek(dw_size, io.SEEK_CUR) + + if not success: + self.last_pos = 0 + return None + + if (self._pos + dw_size) < len(self._buffer): + self.last_pos = self._pos + dw_size + else: + self.last_pos = 0 + + return dw_size + + def open_chunk(self, id: int) -> Optional[XRStream]: + size = self.find_chunk(id) + if size and size != 0: + data = self.read(size) + return XRStream(data) + return None diff --git a/libs/basic_games/games/stalkeranomaly/XRMath.py b/libs/basic_games/games/stalkeranomaly/XRMath.py new file mode 100644 index 0000000..355fcaf --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRMath.py @@ -0,0 +1,37 @@ +class IVec3: + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}" + + +class IVec4(IVec3): + def __init__(self, x: float, y: float, z: float, w: float): + super().__init__(x, y, z) + self.w = w + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}, f{self.w}" + + +class IFlag: + def __init__(self, flag: int): + self._flag = flag + + def __str__(self) -> str: + return str(self._flag) + + def assign(self, mask: int): + self._flag = mask + + def has(self, mask: int) -> bool: + return bool((self._flag & mask) == mask) + + def set(self, mask: int) -> None: + self._flag |= mask + + def remove(self, mask: int) -> None: + self._flag &= ~mask diff --git a/libs/basic_games/games/stalkeranomaly/XRNET.py b/libs/basic_games/games/stalkeranomaly/XRNET.py new file mode 100644 index 0000000..2df87a9 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRNET.py @@ -0,0 +1,45 @@ +from .XRIO import XRReader +from .XRMath import IVec3, IVec4 + + +class XRNETState: + def __init__(self): + self.position = IVec3(0.0, 0.0, 0.0) + self.quaternion = IVec4(0.0, 0.0, 0.0, 0.0) + self.enabled = False + + def read(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + self.position = self.fvec_q8(reader, fmin, fmax) + self.quaternion = self.fqt_q8(reader) + self.enabled = bool(reader.u8()) + + def clamp(self, val: float, low: float, high: float) -> float: + if val < low: + return low + elif val > high: + return high + else: + return val + + def fvec_q8(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + vec = IVec3(0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, fmin.x, fmax.x) + vec.y = self.f_q8(reader, fmin.y, fmax.y) + vec.z = self.f_q8(reader, fmin.z, fmax.z) + vec.x = self.clamp(vec.x, fmin.x, fmax.x) + vec.y = self.clamp(vec.x, fmin.y, fmax.y) + vec.z = self.clamp(vec.z, fmin.z, fmax.z) + + def fqt_q8(self, reader: XRReader): + vec = IVec4(0.0, 0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, -1.0, 1.0) + vec.y = self.f_q8(reader, -1.0, 1.0) + vec.z = self.f_q8(reader, -1.0, 1.0) + vec.w = self.f_q8(reader, -1.0, 1.0) + vec.x = self.clamp(vec.x, -1.0, 1.0) + vec.y = self.clamp(vec.y, -1.0, 1.0) + vec.z = self.clamp(vec.z, -1.0, 1.0) + vec.w = self.clamp(vec.w, -1.0, 1.0) + + def f_q8(self, reader: XRReader, fmin: float, fmax: float): + return (float(reader.u8()) / 255.0) * (fmax - fmin) + fmin diff --git a/libs/basic_games/games/stalkeranomaly/XRObject.py b/libs/basic_games/games/stalkeranomaly/XRObject.py new file mode 100644 index 0000000..d1eccdb --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRObject.py @@ -0,0 +1,277 @@ +# -*- encoding: utf-8 -*- + +import io +from enum import IntFlag +from typing import List + +from .XRIO import XRReader +from .XRMath import IFlag, IVec3 +from .XRNET import XRNETState + + +class XRFlag(IntFlag): + CHUNK_ALIFE = 0x0 + CHUNK_SPAWN = 0x1 + CHUNK_OBJECT = 0x2 + CHUNK_GAME_TIME = 0x5 + CHUNK_REGISTRY = 0x9 + + SPAWN_VERSION = 1 << 5 + + MSG_UPDATE = 0x0 + MSG_SPAWN = 0x1 + + TRADER_INFINITE_AMMO = 0x1 + + +class XRAbstract: + def __init__(self, name: str = ""): + self._valid = False + self.name = name + self.name_replace = "" + self.rp = 0xFE + self.position = IVec3(0.0, 0.0, 0.0) + self.angle = IVec3(0.0, 0.0, 0.0) + self.respawn_time = 0 + self.id = 0xFFFF + self.id_parent = 0xFFFF + self.id_phantom = 0xFFFF + self.flags = IFlag(0) + self.version = 0 + self.game_type = IFlag(0) + self.script_version = 0 + self.client_data: List[int] = [] + self.spawn_id = 0 + self.ini_str = "" + + def read_spawn(self, reader: XRReader): + spawn = reader.u16() + if spawn != XRFlag.MSG_SPAWN: + self._valid = False + return + self.name = reader.str() + self.name2 = reader.str() + reader.seek(1, io.SEEK_CUR) # temp_gt + self.rp = reader.u8() + self.position = reader.fvec3() + self.angle = reader.fvec3() + self.respawn_time = reader.u16() + self.id = reader.u16() + self.id_parent = reader.u16() + self.id_phantom = reader.u16() + self.flags = IFlag(reader.u16()) + if self.flags.has(XRFlag.SPAWN_VERSION): + self.version = reader.u16() + if self.version == 0: + reader._pos -= 2 # pyright: ignore[reportPrivateUsage] + return + if self.version > 120: + self.game_type.set(reader.u16()) + if self.version > 69: + self.script_version = reader.u16() + if self.version > 70: + cl_size = 0 + if self.version > 93: + cl_size = reader.u16() + else: + cl_size = reader.u8() + if cl_size > 0: + for _ in range(cl_size): + data = reader.u8() + self.client_data.append(data) + if self.version > 79: + self.spawn_id = reader.u16() + self._valid = True + + def __bool__(self): + return self._valid + + +class XRVisual: + def __init__(self): + self.visual_name = "" + self.startup_animation = "" + self.flags = IFlag(0) + + def read_visual(self, reader: XRReader, version: int): + self.visual_name = reader.str() + self.flags = IFlag(reader.u8()) + + +class XRBoneData: + def __init__(self): + self.bones_mask = -1 + self.root_bone = 0 + self.min = IVec3(0.0, 0.0, 0.0) + self.max = IVec3(0.0, 0.0, 0.0) + self.bones: list[XRNETState] = [] + + def load(self, reader: XRReader): + self.bones_mask = reader.u64() + self.root_bone = reader.u16() + self.min = reader.fvec3() + self.max = reader.fvec3() + bones_count = reader.u16() + for _ in range(bones_count): + bone = XRNETState() + bone.read(reader, self.min, self.max) + self.bones.append(bone) + + +class XRSkeleton: + def __init__(self): + self.source_id = -1 + self.saved_bones = XRBoneData() + + def read_state(self, reader: XRReader): + self.visual_animation = reader.str() + flags = IFlag(reader.u8()) + self.source_id = reader.u16() + if flags.has(4): + self.saved_bones.load(reader) + + +class XRObject(XRAbstract): + def __init__(self): + super().__init__() + self.graph_id = 0 + self.distance = 0.0 + self.direct_control = True + self.node_id = 0 + self.story_id = -1 + self.spawn_story_id = -1 + + def read_spawn(self, reader: XRReader): + super().read_spawn(reader) + size = reader.u16() + if size <= 2: + self._valid = False + return + self.read_state(reader) + + def read_state(self, reader: XRReader): + self.graph_id = reader.u16() + self.distance = reader.float() + self.direct_control = bool(reader.u32()) + self.node_id = reader.u32() + self.flags.set(reader.u32()) + self.ini_str = reader.str() + self.story_id = reader.u32() + self.spawn_story_id = reader.u32() + + def read_update(self, reader: XRReader): + update = reader.u16() + if update != XRFlag.MSG_UPDATE: + self._valid = False + return + + +class XRDynamicObject(XRObject): + def __init__(self): + super().__init__() + self.time_id = -1 + self.switch_counter = 0 + + +class XRDynamicObjectVisual(XRDynamicObject, XRVisual): + def read_state(self, reader: XRReader): + XRDynamicObject.read_state(self, reader) + if self.version > 31: + XRVisual.read_visual(self, reader, self.version) + + +class XRCreatureAbstract(XRDynamicObjectVisual): + def __init__(self): + super().__init__() + self.team = 0 + self.squad = 0 + self.group = 0 + self.health = 1.0 + self.dynamic_out: list[int] = [] + self.dynamic_in: list[int] = [] + self.killer_id = -1 + self.death_time = 0 + + def read_state(self, reader: XRReader): + super().read_state(reader) + self.team = reader.u8() + self.squad = reader.u8() + self.group = reader.u8() + self.health = reader.float() * 100 + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_out.append(_id) + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_in.append(_id) + + self.killer_id = reader.u16() + self.death_time = reader.u64() + + +class XRTraderAbstract: + def __init__(self): + self.money = 0 + self.max_item_mass = 0 + self.character_name = "" + self.character_name_str = "" + self.character_profile = "" + self.specific_character = "" + self.community_index = -1 + self.rank = -1 # -MAX_INT? + self.reputation = -1 # -MAX_INT? + self.dead_body_can_take = True + self.dead_body_closed = False + self.trader_flags = IFlag(0) + self.trader_flags.remove(XRFlag.TRADER_INFINITE_AMMO) + + def read_state(self, reader: XRReader): + self.money = reader.u32() + self.specific_character = reader.str() + self.trader_flags.assign(reader.u32()) + self.character_profile = reader.str() + self.community_index = reader.s32() + self.rank = reader.s32() + self.reputation = reader.s32() + self.character_name_str = reader.str() + self.dead_body_can_take = reader.u8() == 1 + self.dead_body_closed = reader.u8() == 1 + + +class XRCreatureActor(XRCreatureAbstract, XRTraderAbstract, XRSkeleton): + def __init__(self): + XRCreatureAbstract.__init__(self) + XRTraderAbstract.__init__(self) + XRSkeleton.__init__(self) + self.state = 0 + self.acceleration = IVec3(0.0, 0.0, 0.0) + self.velocity = IVec3(0.0, 0.0, 0.0) + self.radiation = 0.0 + self.weapon = 0 + self.num_items = 0 + self.holder_id = -1 + + def read_spawn(self, reader: XRReader): + XRCreatureAbstract.read_spawn(self, reader) + + def read_state(self, reader: XRReader): + XRCreatureAbstract.read_state(self, reader) + XRTraderAbstract.read_state(self, reader) + XRSkeleton.read_state(self, reader) + self.holder_id = reader.u16() + + def read_update(self, reader: XRReader): + XRCreatureAbstract.read_update(self, reader) + # XRTraderAbstract.read_update(self, reader) # Future? + self.state = reader.u16() + # acceleration (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + # velocity (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + self.radiation = reader.float() + self.weapon = reader.u8() + self.num_items = reader.u16() diff --git a/libs/basic_games/games/stalkeranomaly/XRSave.py b/libs/basic_games/games/stalkeranomaly/XRSave.py new file mode 100644 index 0000000..b73a988 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRSave.py @@ -0,0 +1,164 @@ +import io +import struct +from datetime import datetime +from pathlib import Path +from typing import BinaryIO, Optional, cast + +import lzokay # pyright: ignore[reportMissingTypeStubs] + +from .XRIO import XRReader, XRStream +from .XRObject import XRCreatureActor, XRFlag + + +class XRSave: + filepath: Path + player: XRCreatureActor + + _factions = { + 0: "Loner", + 1: "Monster", + 2: "Trader", + 3: "Army", + 4: "Sin", + 5: "Bandit", + 6: "Duty", + 7: "Ecologist", + 8: "Freedom", + 9: "Mercenary", + 10: "Army", + 11: "Monolith", + 12: "Sin", + 13: "Loner", + 14: "Zombified", + 15: "Clear Sky", + 16: "UNISG", + 17: "Renegade", + 18: "Loner", + 19: "Bandit", + 20: "Duty", + 21: "Freedom", + 22: "Clear Sky", + 23: "Ecologist", + 24: "Mercenary", + 25: "Military", + 26: "Monolith", + 27: "Zombified", + 28: "Sin", + 29: "UNISG", + 30: "Renegade", + 31: "Participant", + } + + _ranks = { + 1999: "Novice", + 3999: "Trainee", + 6999: "Experienced", + 9999: "Professional", + 14999: "Veteran", + 20999: "Expert", + 27999: "Master", + "max": "Legend", + } + + _reputation = { + -2000: "Terrible", + -1500: "Really Bad", + -1000: "Very Bad", + -500: "Bad", + 499: "Netural", + 999: "Good", + 1499: "Very Good", + 1999: "Really Good", + "max": "Excellent", + } + + def __init__(self, filepath: Path): + self.filepath = filepath + self.fetchInfo() + with open(filepath, "rb") as file: + stream = self.readFile(file) + if stream: + self.readObject(stream) + + def fetchInfo(self): + self.splitInfo() + self.time = self.filepath.stat().st_mtime + self.time_date = datetime.fromtimestamp(self.time) + self.time_fmt = self.time_date.strftime("%I:%M %m/%d/%Y") + + def splitInfo(self): + save_clean = self.filepath.name.split(".scop", 1)[0] + save_split = save_clean.split(" - ", 1) + self.user = save_split[0] + if len(save_split) > 1: + save_end = save_split[1].split("_", 1) + if len(save_end) > 1: + self.save_fmt = f"{save_end[0]} (#{save_end[1]})".title() + else: + self.save_fmt = f"{save_end[0]}".title() + else: + self.save_fmt = "Unknown" + + def readFile(self, file: BinaryIO) -> Optional[XRStream]: + size = self.filepath.stat().st_size + if size < 8: + return None + + (start, version, source) = struct.unpack("@iii", file.read(12)) + if (start == -1) and (version >= 6): + file.seek(12) + data = file.read(size - 12) + return XRStream( + cast( + bytes, + lzokay.decompress( # pyright: ignore[reportUnknownMemberType] + data, source + ), + ) + ) + + return None + + def readObject(self, stream: XRStream): + chunk = stream.open_chunk(XRFlag.CHUNK_OBJECT) + if chunk: + chunk.seek(4, io.SEEK_CUR) # obj_count + count_spawn = chunk.u16() + spawn = XRReader(chunk.read(count_spawn)) + actor = XRCreatureActor() + actor.read_spawn(spawn) + count_update = chunk.u16() + update = XRReader(chunk.read(count_update)) + actor.read_update(update) + if actor: + self.player = actor + return None + + def getFaction(self) -> str: + player = self.player + if player: + player_faction = player.community_index + for faction in self._factions: + if player_faction == faction: + return self._factions[faction] + return "Unknown" + + def getRank(self) -> str: + player = self.player + if player: + player_rank = player.rank + for rank in self._ranks: + if isinstance(rank, int): + if player_rank <= rank: + return self._ranks[rank] + return self._ranks["max"] + + def getReputation(self) -> str: + player = self.player + if player: + player_rep = player.reputation + for rep in self._reputation: + if isinstance(rep, int): + if player_rep <= rep: + return self._reputation[rep] + return self._reputation["max"] diff --git a/libs/basic_games/games/stalkeranomaly/__init__.py b/libs/basic_games/games/stalkeranomaly/__init__.py new file mode 100644 index 0000000..a8730a4 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/__init__.py @@ -0,0 +1,38 @@ +from .XRIO import XRReader, XRStream +from .XRMath import IFlag, IVec3, IVec4 +from .XRNET import XRNETState +from .XRObject import ( + XRAbstract, + XRBoneData, + XRCreatureAbstract, + XRCreatureActor, + XRDynamicObject, + XRDynamicObjectVisual, + XRFlag, + XRObject, + XRSkeleton, + XRTraderAbstract, + XRVisual, +) +from .XRSave import XRSave + +__all__ = [ + "IFlag", + "IVec3", + "IVec4", + "XRAbstract", + "XRBoneData", + "XRCreatureAbstract", + "XRCreatureActor", + "XRDynamicObject", + "XRDynamicObjectVisual", + "XRFlag", + "XRNETState", + "XRObject", + "XRReader", + "XRSave", + "XRSkeleton", + "XRStream", + "XRTraderAbstract", + "XRVisual", +] |
