Skip to content

Commit e35488c

Browse files
authored
Features/monsterfish picture load (#28)
* add cv2 as requirement * feat(memory): terrain and height from image POC * CI: sync PR build and test strategy * add cv2 as dependency to setup.py * add logic info * unfinished logic values * feat(get_terrain): working color display * feat(get_height): working height and palette * fix(set_terrain): make sure border edges are retained * fix(set_terrain): change colors.Palette into Palette * fix(main): fixed relative import * CI: add onedir and onefile
1 parent 0860687 commit e35488c

12 files changed

Lines changed: 500 additions & 7 deletions

File tree

.github/workflows/python-pr.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ jobs:
6666
shell: bash
6767
run: |
6868
python -m pip install pyinstaller
69-
pyinstaller --console --onefile ./sourcehold/__main__.py --name sourcehold
70-
69+
pyinstaller --console --onefile ./sourcehold/__main__.py --name sourcehold --distpath dist/sourcehold-onefile
70+
pyinstaller --console --onedir ./sourcehold/__main__.py --name sourcehold --distpath dist/sourcehold-onedir
71+
7172
- name: Archive packages
7273
uses: actions/upload-artifact@v4
7374
with:
@@ -83,9 +84,9 @@ jobs:
8384
matrix:
8485
# os: [ubuntu-latest, windows-latest, macOS-latest]
8586
os: [windows-latest]
86-
platform: [x86, x64]
87-
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
88-
python-platform: [x86, x64]
87+
platform: [x86]
88+
python-version: ['3.8']
89+
python-platform: [x86]
8990
steps:
9091
- name: Download package
9192
uses: actions/download-artifact@v4.1.7

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ pymem
33
dclimplode
44
numpy
55
build
6+
opencv-python

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"Operating System :: OS Independent",
2222
],
2323
python_requires='>=3.8',
24-
install_requires=["pymem", "Pillow", "dclimplode", "numpy"],
24+
install_requires=["pymem", "Pillow", "dclimplode", "numpy", "opencv-python"],
2525
test_suite="tests",
2626
entry_points={
2727
'console_scripts': ['sourcehold=sourcehold:entry_point']

sourcehold/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from sourcehold.tool.argparsers.common import main_parser
1212
from sourcehold.tool.argparsers.services import services_parser, convert_parser
13+
from sourcehold.tool.memory.map import memory_map
1314
from sourcehold.tool.modify.map import modify_map
1415

1516
file_input_output = argparse.ArgumentParser(add_help=False)
@@ -75,13 +76,15 @@
7576

7677
args = main_parser.parse_args()
7778

78-
from .tool.convert.aiv import convert_aiv
79+
from sourcehold.tool.convert.aiv import convert_aiv
7980

8081
def main():
8182
if convert_aiv(args):
8283
return
8384
if modify_map(args):
8485
return
86+
if memory_map(args):
87+
return
8588

8689
if args.service == "aiv":
8790
if args.method == "file":

sourcehold/tool/argparsers/services.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import argparse
12
from .common import main_parser, file_input_file_output
23

34
services_parser = main_parser.add_subparsers(title="service", dest="service", required=True)
@@ -11,6 +12,25 @@
1112
convert_aiv_parser.add_argument('--to-format', required=False, default='')
1213

1314
memory_parser = services_parser.add_parser('memory')
15+
memory_parser.add_argument('--game', choices=['SHC1.41-latin', "SHCE1.41-latin"], default="SHC1.41-latin")
16+
memory_subparsers = memory_parser.add_subparsers(dest='type', required=True, title='type')
17+
18+
memory_map_parser = memory_subparsers.add_parser('map')
19+
memory_map_subparsers = memory_map_parser.add_subparsers(dest='action', required=True)
20+
21+
memory_common = argparse.ArgumentParser(add_help=False)
22+
memory_common.add_argument('what', choices=['terrain', 'height'])
23+
memory_common.add_argument('--palette', default='', required=False)
24+
25+
memory_map_get_parser = memory_map_subparsers.add_parser('get', parents=[memory_common])
26+
memory_map_get_parser.add_argument('--output', default='')
27+
memory_map_get_parser.add_argument('--output-format', default='png', choices=['png'])
28+
29+
memory_map_set_parser = memory_map_subparsers.add_parser('set', parents=[memory_common])
30+
memory_map_set_parser.add_argument('--input', default='-')
31+
memory_map_set_parser.add_argument('--input-format', default='', choices=['png'])
32+
33+
1434

1535
modify_parser = services_parser.add_parser('modify')
1636
modify_subparser = modify_parser.add_subparsers(dest='type', required=True, title='type')

sourcehold/tool/memory/__init__.py

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pathlib, sys
2+
3+
from sourcehold.tool.convert.aiv.exports import to_json
4+
from sourcehold.tool.convert.aiv.imports import from_json
5+
from sourcehold.tool.memory.map.height import get_height, set_height
6+
from sourcehold.tool.memory.map.terrain import get_terrain, set_terrain
7+
8+
9+
def memory_map(args):
10+
#' returns None in case of non applicable
11+
if args.service != "memory":
12+
return None
13+
14+
if args.type != "map":
15+
return None
16+
17+
if set_height(args):
18+
return True
19+
20+
21+
if get_height(args):
22+
return True
23+
24+
if set_terrain(args):
25+
return True
26+
27+
if get_terrain(args):
28+
return True
29+
30+
return True
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pathlib
2+
from sourcehold.debugtools.memory.access import SHC, SHCE
3+
4+
def get_process_handle(version):
5+
if version == "SHC1.41-latin":
6+
return SHC()
7+
# if version == "SHCE1.41-latin":
8+
# return SHCE()
9+
raise NotImplementedError(f"process not implemented: {version}")
10+
11+
12+
def validate_input_path(img_path):
13+
if not img_path:
14+
raise Exception(f"no input file specified")
15+
if img_path == "-":
16+
raise NotImplementedError(f"stdin input not yet implemented for this action. Specify a file path using --input")
17+
18+
if not pathlib.Path(img_path).exists():
19+
raise Exception(f"file does not exist: {img_path}")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
2+
3+
4+
# python -m pip install Pillow
5+
import pathlib
6+
import struct
7+
import numpy
8+
from sourcehold.tool.memory.map.common import get_process_handle, validate_input_path
9+
from sourcehold.world import create_selection_matrix
10+
import cv2 as cv # type: ignore
11+
import sys
12+
13+
def get_image_data_grayscale(img_path):
14+
img = cv.imread(img_path)
15+
img = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
16+
return img
17+
18+
selection = create_selection_matrix()
19+
20+
# (Little endian) unsigned bytes
21+
def get_raw_height(process):
22+
return struct.unpack("<80400B", process.read_section('1045'))
23+
24+
def set_raw_height(process, data):
25+
bytes_data = struct.pack("<80400B", *data)
26+
# ChangedLayer
27+
# process.write_bytes(0x01c5ad88, b'\x02' * 80400) # TODO: fix
28+
# Logical terrain height layer: DefaultHeightLayer
29+
process.write_section('1045', bytes_data)
30+
# Visual height layer, I think also includes walls and towers: HeightLayer
31+
process.write_section('1005', bytes_data)
32+
# # LogicLayer
33+
# process.write_section('1003', struct.pack("<80400I", *((v & 0xffffff7f) for v in struct.unpack("<80400I", process.read_section('1003')))))
34+
# # Logic2Layer
35+
# process.write_section('1037', b'\x04' * 80400)
36+
37+
# def post_process_raw_height():
38+
# # MiscDisplayLayer
39+
# process.write_section('1007', struct.pack("<80400H", *(((v & 0xffdf) & 0xf83f) for v in struct.unpack("<80400H", process.read_section('1007')))))
40+
# # LogicLayer, what a hot mess, probably not all required
41+
# process.write_section('1003', struct.pack("<80400I", *(((((v & 0x5f81c436) & 0xffffff7f) & 0xbfffbfff) | 32768) for v in struct.unpack("<80400I", process.read_section('1003')))))
42+
# # Logic2Layer
43+
# process.write_section('1037', b'\x04' * 80400)
44+
# # TODO: wall owner layer, and special logic2layer set to 4 or 8 depending on plateau
45+
# # ChangedLayer
46+
# process.write_bytes(0x01c5ad88, b'\x02' * 80400)
47+
48+
49+
# post_process_raw_height()
50+
51+
52+
def set_height(args):
53+
#' returns None in case of non applicable
54+
if args.what != 'height':
55+
return None
56+
57+
if args.action != "set":
58+
return None
59+
60+
img_path = args.input
61+
validate_input_path(img_path)
62+
63+
img = get_image_data_grayscale(img_path)
64+
65+
process = get_process_handle(args.game)
66+
67+
set_raw_height(process, img[selection].flat)
68+
69+
return True
70+
71+
72+
73+
def get_height(args):
74+
#' returns None in case of non applicable
75+
if args.what != 'height':
76+
return None
77+
78+
if args.action != "get":
79+
return None
80+
81+
img = numpy.zeros((400,400), dtype='uint8')
82+
83+
process = get_process_handle(args.game)
84+
85+
height = numpy.zeros((400, 400), dtype='uint8')
86+
height[selection] = get_raw_height(process)
87+
88+
img[selection] = height[selection]
89+
90+
if not args.output:
91+
print(args.output_format)
92+
sys.stdout.buffer.write(cv.imencode(f".{args.output_format}", img)[1].tobytes())
93+
else:
94+
cv.imwrite(args.output, img=img)
95+
96+
return True
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
MAP_SIZE = 400
2+
TILE_COUNT = MAP_SIZE * ((MAP_SIZE // 2) + 1 )
3+
# python -m pip install Pillow
4+
import struct, numpy
5+
from sourcehold.tool.memory.map.common import get_process_handle, validate_input_path
6+
from sourcehold.world import create_selection_matrix
7+
import cv2 as cv # type: ignore
8+
from .logics import logic1, logic1_vk, logic2, logic2_vk
9+
from .colors import DEFAULT_PALETTE, Palette
10+
import sys
11+
12+
def get_image_data(img_path):
13+
img = cv.imread(img_path)
14+
return img
15+
16+
selection = create_selection_matrix(size=MAP_SIZE)
17+
18+
# (Little endian) unsigned bytes
19+
def get_raw_logic1(process):
20+
return struct.unpack(f"<{TILE_COUNT}I", process.read_section('1003'))
21+
22+
def set_raw_logic1(process, data):
23+
serialized = struct.pack(f"<{TILE_COUNT}I", *data)
24+
# Logical terrain height layer
25+
process.write_section('1003', serialized)
26+
27+
def get_raw_logic2(process):
28+
return struct.unpack(f"<{TILE_COUNT}B", process.read_section('1037'))
29+
30+
def set_raw_logic2(process, data):
31+
serialized = struct.pack(f"<{TILE_COUNT}B", *data)
32+
# Logical terrain height layer
33+
process.write_section('1037', serialized)
34+
35+
36+
# 16 is used for the inaccessible parts of the map, including the outer border of the 800x800 space, and 32 is used for a border just within that
37+
38+
def set_terrain(args):
39+
#' returns None in case of non applicable
40+
if args.what != 'terrain':
41+
return None
42+
43+
if args.action != "set":
44+
return None
45+
46+
if args.palette:
47+
palette = Palette(args.palette)
48+
else:
49+
palette = DEFAULT_PALETTE
50+
51+
img_path = args.input
52+
validate_input_path(img_path)
53+
img = get_image_data(img_path)
54+
55+
process = get_process_handle(args.game)
56+
57+
logic1matrix = numpy.zeros((400, 400), dtype='uint32')
58+
borderlogic1matrix = numpy.zeros((400, 400), dtype='uint32')
59+
borderlogic1matrix[selection] = get_raw_logic1(process) # We load it here to get the map borders only...
60+
logic1matrix[borderlogic1matrix & logic1['border'] != 0] |= logic1['border']
61+
logic1matrix[borderlogic1matrix & logic1['border_edge'] != 0] |= logic1['border_edge']
62+
logic1matrix[borderlogic1matrix == 0] = 0
63+
64+
logic2matrix = numpy.zeros((400, 400), dtype='uint8')
65+
# logic2matrix[selection] = get_raw_logic2(process)
66+
67+
for color, name in palette.bgr_palette.items():
68+
where = (img == color).all(2)
69+
logic1matrix[where] |= logic1[name]
70+
if name in logic2:
71+
logic2matrix[where] = logic2[name]
72+
73+
set_raw_logic1(process, logic1matrix[selection].flat)
74+
set_raw_logic2(process, logic2matrix[selection].flat)
75+
76+
return True
77+
78+
def get_terrain(args):
79+
#' returns None in case of non applicable
80+
if args.what != 'terrain':
81+
return None
82+
83+
if args.action != "get":
84+
return None
85+
86+
if args.palette:
87+
palette = Palette(args.palette)
88+
else:
89+
palette = DEFAULT_PALETTE
90+
91+
process = get_process_handle(args.game)
92+
93+
logic1matrix = numpy.zeros((400, 400), dtype='uint32')
94+
logic1matrix[selection] = get_raw_logic1(process)
95+
96+
logic2matrix = numpy.zeros((400, 400), dtype='uint8')
97+
logic2matrix[selection] = get_raw_logic2(process)
98+
99+
img = numpy.zeros((400,400,3), dtype='uint8')
100+
101+
if args.debug:
102+
print("logic1")
103+
for flag, name in logic1_vk.items():
104+
color = (0, 0, 0)
105+
if name in palette.palette_bgr:
106+
color = palette.palette_bgr[name]
107+
else:
108+
if args.debug:
109+
print(f"skipping color for: {name}")
110+
img[logic1matrix & flag != 0] = color
111+
if args.debug:
112+
print(f"set '{name}' {img[logic1matrix & flag != 0].sum()} times to color: {palette.bgr_palette[color]}")
113+
114+
if args.debug:
115+
print("logic2")
116+
for flag, name in logic2_vk.items():
117+
if name == 'none':
118+
continue
119+
color = (0, 0, 0)
120+
where = (logic1matrix & logic1['default_earth_or_texture']) != 0
121+
if name in palette.palette_bgr:
122+
color = palette.palette_bgr[name]
123+
else:
124+
if args.debug:
125+
print(f"skipping color for: {name}")
126+
img[where & (logic2matrix == flag)] = color
127+
if args.debug:
128+
print(f"set '{name}' {img[where & (logic2matrix == flag)].sum()} times to color: {palette.bgr_palette[color]}")
129+
130+
if not args.output:
131+
if args.debug:
132+
print(args.output_format)
133+
sys.stdout.buffer.write(cv.imencode(f".{args.output_format}", img)[1].tobytes())
134+
else:
135+
cv.imwrite(args.output, img=img)
136+
137+
return True

0 commit comments

Comments
 (0)