Updated for VASL 6.5.0.

master
Pacman Ghost 4 years ago
parent 3099f73268
commit d24c854387
  1. 2
      vasl_templates/webapp/config/site.cfg.example
  2. 4
      vasl_templates/webapp/static/help/index.html
  3. BIN
      vasl_templates/webapp/tests/fixtures/analyze-vsav/reverse-remapped-gpids-650.vsav
  4. 8
      vasl_templates/webapp/tests/fixtures/gpid-remapping.json
  5. 1219
      vasl_templates/webapp/tests/fixtures/vasl-pieces-6.5.0.txt
  6. 1219
      vasl_templates/webapp/tests/fixtures/vasl-pieces-legacy.txt
  7. 1219
      vasl_templates/webapp/tests/fixtures/vasl-pieces.txt
  8. 5
      vasl_templates/webapp/tests/remote.py
  9. 72
      vasl_templates/webapp/tests/test_counters.py
  10. 4
      vasl_templates/webapp/tests/test_files.py
  11. 44
      vasl_templates/webapp/tests/test_vassal.py
  12. 90
      vasl_templates/webapp/vasl_mod.py
  13. 19
      vasl_templates/webapp/vassal.py

@ -2,7 +2,7 @@
; configure VASSAL and VASL
VASSAL_DIR = ...configure the VASSAL installation directory...
VASL_MOD = ...configure the VASL module (e.g. vasl-6.4.4.vmod)...
VASL_MOD = ...configure the VASL module (e.g. vasl-6.5.0.vmod)...
VASL_EXTNS_DIR = ...configured the VASL extensions directory...
BOARDS_DIR = ...configure the VASL boards directory...

@ -79,7 +79,7 @@ and then connect to it in a browser at <tt>http://localhost:5010</tt>.
<p> If you have Docker installed, the webapp can be run in a container e.g.
<div class="code">
./run-container.sh --port 5010 \
--vasl-vmod ~/vasl/vasl-6.4.4.vmod \
--vasl-vmod ~/vasl/vasl-6.5.0.vmod \
--vasl-extensions ~/vasl/extensions/ \
--chapter-h ~/vasl/chapter-h/
</div>
@ -132,7 +132,7 @@ The first thing we want to do is configure the program.
<p> Choose <em>Settings</em> from the <em>File</em> menu and configure the highlighted settings. As a guide, here are some example settings:
<table class="settings">
<tr> <td class="key"> VASSAL installation: </td> <td class="val"> C:/bin/vassal-3.2.17/
<tr> <td class="key"> VASL module: </td> <td class="val"> C:/bin/vasl/vasl-6.4.4.vmod
<tr> <td class="key"> VASL module: </td> <td class="val"> C:/bin/vasl/vasl-6.5.0.vmod
<tr> <td class="key"> VASL extensions: </td> <td class="val"> C:/bin/vasl/extensions/
<tr> <td class="key"> VASL boards: </td> <td class="val"> C:/bin/vasl/boards/
<tr> <td class="key"> Java: </td> <td class="val"> C:/bin/jPortable-8u201-x64/bin/java.exe

@ -2,7 +2,11 @@
"SCENARIO_NAME": "GPID remapping test",
"PLAYER_1": "german",
"OB_VEHICLES_1": [
{ "id":"ge/v:106", "name":"SdKfz 10/5", "image_id":"7140/0" },
{ "id":"ge/v:105", "name":"SdKfz 10/4" }
{ "id": "ge/v:009", "name": "FT-17 730m(f)" },
{ "id": "ge/v:009", "name": "FT-17 730m(f)", "image_id": "7124/0" }
],
"PLAYER_2": "american",
"OB_ORDNANCE_2": [
{ "id": "am/o:002", "name": "M1 81mm Mortar" }
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -64,6 +64,8 @@ class ControlTests:
"""Invoke a handler function on the remote server."""
if "bin_data" in kwargs:
kwargs["bin_data"] = base64.b64encode( kwargs["bin_data"] )
if "gpids" in kwargs:
kwargs["gpids"] = json.dumps( kwargs["gpids"] )
resp = urllib.request.urlopen(
self.webapp.url_for( "control_tests", action=action, **kwargs )
).read()
@ -117,7 +119,8 @@ class ControlTests:
"""Configure the GPID remappings."""
if isinstance( gpids, str ):
gpids = json.loads( gpids.replace( "'", '"' ) )
gpids = { str(k): v for k,v in gpids.items() }
for row in gpids:
row[1] = { str(k): v for k,v in row[1].items() }
_logger.info( "Setting GPID remappings: %s", gpids )
prev_gpid_mappings = vasl_mod_module.GPID_REMAPPINGS
vasl_mod_module.GPID_REMAPPINGS = gpids

@ -9,7 +9,7 @@ import urllib.request
import pytest
import tabulate
from vasl_templates.webapp.vasl_mod import VaslMod, get_vo_gpids
from vasl_templates.webapp.vasl_mod import VaslMod, get_vo_gpids, compare_vasl_versions, SUPPORTED_VASL_MOD_VERSIONS
from vasl_templates.webapp.config.constants import DATA_DIR
from vasl_templates.webapp.tests.utils import init_webapp, select_tab, find_child, find_children
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario
@ -30,10 +30,7 @@ def test_counter_images( webapp ):
# NOTE: This is ridiculously slow on Windows :-/
# figure out which pieces we're interested in
gpids = get_vo_gpids( DATA_DIR, None )
def check_images( check_front, check_back ): #pylint: disable=unused-argument
def check_images( gpids, check_front, check_back ): #pylint: disable=unused-argument
"""Check getting the front and back images for each counter."""
for gpid in gpids:
for side in ("front","back"):
@ -50,16 +47,17 @@ def test_counter_images( webapp ):
# test counter images when no VASL module has been configured
control_tests = ControlTests( webapp )
control_tests.set_vasl_mod( vmod=None )
# NOTE: It doesn't really matter which set of GPID's we use, since we're expecting
# a missing image for everything anyway. We just use the most recent supported version.
gpids = get_vo_gpids( SUPPORTED_VASL_MOD_VERSIONS[-1], DATA_DIR, None )
fname = os.path.join( os.path.split(__file__)[0], "../static/images/missing-image.png" )
missing_image_data = open( fname, "rb" ).read()
check_images(
check_images( gpids,
check_front = lambda code,data: code == 200 and data == missing_image_data,
check_back = lambda code,data: code == 200 and data == missing_image_data
)
# test each VASL module file in the specified directory
fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-pieces.txt" )
expected_vasl_pieces = open( fname, "r" ).read()
vmod_fnames = control_tests.get_vasl_mods()
for vmod_fname in vmod_fnames:
@ -72,14 +70,23 @@ def test_counter_images( webapp ):
vasl_mods_dir = pytest.config.option.vasl_mods #pylint: disable=no-member
fname = os.path.join( vasl_mods_dir, fname )
# check the pieces loaded
# figure out what we're expecting to see
# NOTE: The results were the same across 6.4.0-6.4.4, but 6.5.0 introduced some changes.
vasl_mod = VaslMod( fname, DATA_DIR, None )
dname = os.path.join( os.path.split(__file__)[0], "fixtures" )
fname = os.path.join( dname, "vasl-pieces-{}.txt".format( vasl_mod.vasl_version ) )
if not os.path.isfile( fname ):
fname = os.path.join( dname, "vasl-pieces-legacy.txt" )
expected_vasl_pieces = open( fname, "r" ).read()
# check the pieces loaded
buf = io.StringIO()
_dump_pieces( vasl_mod, buf )
assert buf.getvalue() == expected_vasl_pieces
# check each counter
check_images(
gpids = get_vo_gpids( vasl_mod.vasl_version, DATA_DIR, None )
check_images( gpids,
check_front = lambda code,data: code == 200 and data,
check_back = lambda code,data: (code == 200 and data) or (code == 404 and not data)
)
@ -92,12 +99,20 @@ def _dump_pieces( vasl_mod, out ):
# dump the VASL pieces
results = [ [ "GPID", "Name", "Front images", "Back images"] ]
pieces = vasl_mod._pieces #pylint: disable=protected-access
gpids = sorted( pieces.keys(), key=int ) # nb: because GPID's changed from int to str :-/
# GPID's were originally int's but then changed to str's. We then started seeing non-numeric GPID's :-/
# For back-compat, we try to maintain sort order for numeric values.
def sort_key( val ): #pylint: disable=missing-docstring
if val.isdigit():
return ( "0"*10 + val )[-10:]
else:
# nb: we make sure that alphanumeric values appear after numeric values, even if they start with a number
return "_" + val
gpids = sorted( pieces.keys(), key=sort_key ) # nb: because GPID's changed from int to str :-/
for gpid in gpids:
piece = pieces[ gpid ]
assert piece["gpid"] == gpid
results.append( [ gpid, piece["name"], piece["front_images"], piece["back_images"] ] )
print( tabulate.tabulate( results, headers="firstrow" ), file=out )
print( tabulate.tabulate( results, headers="firstrow", numalign="left" ), file=out )
# ---------------------------------------------------------------------
@ -147,8 +162,14 @@ def test_gpid_remapping( webapp, webdriver ):
vehicles_sortable = find_child( "#ob_vehicles-sortable_1" )
entries = find_children( "li", vehicles_sortable )
assert len(entries) == 2
check_entry( entries[0], "/counter/7140/front/0", valid_images )
check_entry( entries[1], "/counter/7146/front", valid_images )
check_entry( entries[0], "/counter/2542/front", True )
check_entry( entries[1], "/counter/7124/front/0", valid_images )
# check that the American ordnance loaded correctly
select_tab( "ob2" )
vehicles_sortable = find_child( "#ob_ordnance-sortable_2" )
entries = find_children( "li", vehicles_sortable )
assert len(entries) == 1
check_entry( entries[0], "/counter/879/front", valid_images )
# load the test scenario
fname = os.path.join( os.path.split(__file__)[0], "fixtures/gpid-remapping.json" )
@ -162,16 +183,27 @@ def test_gpid_remapping( webapp, webdriver ):
assert len(matches) == 1
return matches[0]
# run the tests using VASL 6.4.2 and 6.4.3
do_test( find_vasl_mod("6.4.2"), True )
do_test( find_vasl_mod("6.4.3"), True )
# run the tests using VASL 6.4.4 and 6.5.0
do_test( find_vasl_mod("6.4.4"), True )
do_test( find_vasl_mod("6.5.0"), True )
# disable GPID remapping and try again
prev_gpid_mappings = control_tests.set_gpid_remappings( gpids={} )
prev_gpid_mappings = control_tests.set_gpid_remappings( gpids=[] )
try:
do_test( find_vasl_mod("6.4.2"), True )
do_test( find_vasl_mod("6.4.3"), False )
do_test( find_vasl_mod("6.4.4"), True )
do_test( find_vasl_mod("6.5.0"), False )
finally:
# NOTE: This won't get done if Python exits unexpectedly in the try block,
# which will leave the server in the wrong state if it's remote.
control_tests.set_gpid_remappings( gpids=prev_gpid_mappings )
# ---------------------------------------------------------------------
def test_compare_vasl_versions():
"""Test comparing VASL version strings."""
for i,vasl_version in enumerate(SUPPORTED_VASL_MOD_VERSIONS):
if i > 0:
assert compare_vasl_versions( SUPPORTED_VASL_MOD_VERSIONS[i-1], vasl_version ) < 0
assert compare_vasl_versions( vasl_version, vasl_version ) == 0
if i < len(SUPPORTED_VASL_MOD_VERSIONS)-1:
assert compare_vasl_versions( vasl_version, SUPPORTED_VASL_MOD_VERSIONS[i+1] ) < 0

@ -117,10 +117,10 @@ def test_local_user_files( webapp, webdriver ):
assert ex.code == 404
# try getting a file outside the configured directory (nb: should always fail)
fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-pieces.txt" )
fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-pieces-legacy.txt" )
assert os.path.isfile( fname )
with pytest.raises( urllib.error.HTTPError ) as exc_info:
url = webapp.url_for( "get_user_file", path="../vasl-pieces.txt" )
url = webapp.url_for( "get_user_file", path="../vasl-pieces-legacy.txt" )
resp = urllib.request.urlopen( url )
assert exc_info.value.code == 404

@ -10,6 +10,7 @@ import typing.re #pylint: disable=import-error
import pytest
from vasl_templates.webapp.vassal import VassalShim
from vasl_templates.webapp.vasl_mod import compare_vasl_versions
from vasl_templates.webapp.utils import TempFile, change_extn
from vasl_templates.webapp import globvars
from vasl_templates.webapp.tests.utils import \
@ -604,9 +605,38 @@ def test_analyze_vsav_hip_concealed( webapp, webdriver ):
# run the test against all versions of VASSAL+VASL
_run_tests( control_tests, do_test, not pytest.config.option.short_tests ) #pylint: disable=no-member
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
def test_reverse_remapped_gpids( webapp, webdriver ):
"""Test reverse mapping of GPID's."""
# initialize
control_tests = init_webapp( webapp, webdriver, vsav_persistence=1, scenario_persistence=1,
reset = lambda ct: ct.set_data_dir( dtype="real" )
)
def do_test(): #pylint: disable=missing-docstring
new_scenario()
set_player( 1, "american" )
set_player( 2, "croatian" )
_analyze_vsav( "reverse-remapped-gpids-650.vsav",
[ ["am/v:044"], ["am/o:002","am/o:021"] ],
[ ["cr/v:002","cr/v:003"], ["cr/o:000"] ],
[ "Imported 1 American vehicle and 2 ordnance.", "Imported 2 Croatian vehicles and 1 ordnance." ]
)
# run the test against all versions of VASSAL+VASL
_run_tests( control_tests, do_test,
not pytest.config.option.short_tests, #pylint: disable=no-member
min_vasl_version="6.5.0"
)
# ---------------------------------------------------------------------
def _run_tests( control_tests, func, test_all ):
def _run_tests( control_tests, func, test_all, min_vasl_version=None ):
"""Run the test function for each combination of VASSAL + VASL.
This is, of course, going to be insanely slow, since we need to spin up a JVM
@ -634,6 +664,12 @@ def _run_tests( control_tests, func, test_all ):
for vassal_engine in vassal_engines:
control_tests.set_vassal_engine( vengine=vassal_engine )
for vasl_mod in vasl_mods:
# FUDGE! We assume the version number is part of the filename. Otherwise, we have to load
# the vmod, extract the buildFile, parse the XML, etc. :-/
mo = re.search( r"\d+\.\d+\.\d+", vasl_mod )
vasl_version = mo.group()
if min_vasl_version and compare_vasl_versions( vasl_version, min_vasl_version ) < 0:
continue
control_tests.set_vasl_mod( vmod=vasl_mod )
func()
@ -730,7 +766,11 @@ def _check_vsav_dump( vsav_dump, expected, ignore=None ):
def _get_vsav_labels( vsav_dump ):
"""Extract the labels from a VSAV dump."""
matches = re.finditer( r"AddPiece: DynamicProperty/User-Labeled.*?- Map", vsav_dump, re.DOTALL )
# NOTE: We used to see things like:
# Map0;119;44;6295
# but from 6.5.0, we're getting:
# Main Map;119;44;6295
matches = re.finditer( r"AddPiece: DynamicProperty/User-Labeled.*?- (Main )?Map", vsav_dump, re.DOTALL )
labels = [ mo.group() for mo in matches ]
regex = re.compile( r"<html>.*?</html>" )
matches = [ regex.search(label) for label in labels ]

@ -13,8 +13,8 @@ _logger = logging.getLogger( "vasl_mod" )
from vasl_templates.webapp import app, globvars
from vasl_templates.webapp.config.constants import DATA_DIR
SUPPORTED_VASL_MOD_VERSIONS = [ "6.4.0", "6.4.1", "6.4.2", "6.4.3", "6.4.4" ]
SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.4.0-6.4.4"
SUPPORTED_VASL_MOD_VERSIONS = [ "6.4.0", "6.4.1", "6.4.2", "6.4.3", "6.4.4", "6.5.0" ]
SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.4.0-6.5.0"
warnings = [] # nb: for the test suite
@ -167,10 +167,10 @@ class VaslMod:
"""Get the image for the specified piece."""
# get the image path
gpid = get_effective_gpid( gpid )
gpid = get_remapped_gpid( self.vasl_version, gpid )
if gpid not in self._pieces:
return None, None
piece = self._pieces[ get_effective_gpid( gpid ) ]
piece = self._pieces[ gpid ]
assert side in ("front","back")
image_paths = piece[ side + "_images" ]
if not image_paths:
@ -198,7 +198,7 @@ class VaslMod:
paths = piece[ "front_images" ]
return paths if isinstance(paths,list) else [paths]
return {
p["gpid"]: {
get_reverse_remapped_gpid( self.vasl_version, p["gpid"] ): {
"name": p["name"],
"front_images": image_count( p, "front_images" ),
"back_images": image_count( p, "back_images" ),
@ -225,8 +225,13 @@ class VaslMod:
fname = os.path.join( data_dir, "expected-multiple-images.json" )
expected_multiple_images = json.load( open( fname, "r" ) )
# get the VASL version
build_info = self._files[0][0].read( "buildFile" )
doc = xml.etree.ElementTree.fromstring( build_info )
vasl_version = doc.attrib.get( "version" )
# figure out which pieces we're interested in
target_gpids = get_vo_gpids( data_dir, self.get_extns() )
target_gpids = get_vo_gpids( vasl_version, data_dir, self.get_extns() )
# parse the VASL module and any extensions
for i,files in enumerate( self._files ):
@ -330,6 +335,8 @@ class VaslMod:
return False
if val.endswith( (".gif",".png") ):
return True
if val.startswith( "," ):
val = val[1:]
if val.startswith( ("ru/","ge/","am/","br/","it/","ja/","ch/","sh/","fr/","al/","ax/","hu/","fi/") ):
return True
return False
@ -406,7 +413,7 @@ class VaslMod:
# ---------------------------------------------------------------------
def get_vo_gpids( data_dir, extns ): #pylint: disable=too-many-locals,too-many-branches
def get_vo_gpids( vasl_version, data_dir, extns ): #pylint: disable=too-many-locals,too-many-branches
"""Get the GPID's for the vehicles/ordnance."""
gpids = set()
@ -434,7 +441,7 @@ def get_vo_gpids( data_dir, extns ): #pylint: disable=too-many-locals,too-many-b
entry_gpids = [ entry_gpids ]
for gpid in entry_gpids:
if gpid:
gpids.add( get_effective_gpid( str(gpid) ) )
gpids.add( get_remapped_gpid( vasl_version, str(gpid) ) )
# process any extensions
if extns: #pylint: disable=too-many-nested-blocks
@ -452,6 +459,16 @@ def get_vo_gpids( data_dir, extns ): #pylint: disable=too-many-locals,too-many-b
return gpids
def compare_vasl_versions( lhs, rhs ):
"""Compare two VASL version strings."""
# NOTE: We can do this with a simple string comparison, but see test_compare_vasl_versions().
if lhs < rhs:
return -1
elif lhs > rhs:
return +1
else:
return 0
# ---------------------------------------------------------------------
# VASL 6.4.3 removed several PieceSlot's. There's no comment for the commmit (0a27c24)
@ -461,11 +478,52 @@ def get_vo_gpids( data_dir, extns ): #pylint: disable=too-many-locals,too-many-b
# but we can't just remove the now-missing GPID's, since any scenarios that use them
# will break. This kind of thing is going to happen again, so we provide a generic mechanism
# for dealing with this kind of thing...
GPID_REMAPPINGS = {
"7140": "2775", # SdKfz 10/5
"7146": "2772", # SdKfz 10/4
}
def get_effective_gpid( gpid ):
"""Return the effective GPID."""
return GPID_REMAPPINGS.get( gpid, gpid )
# VASL 6.5.0 introduced a bunch of changes, where pieces were mysteriously assigned a new GPID :-/
GPID_REMAPPINGS = [
[ "6.4.3", {
"7140": "2775", # SdKfz 10/5
"7146": "2772", # SdKfz 10/4
} ],
[ "6.5.0", {
"879": "12483", # 81* MTR M1 (American)
"900": "3b5:3741", # 12.7 AA M51 (American)
"1002": "11340", # M8 AC (American)
"1380": "3b5:7681", # Churchill Bridgelayer (British)
"3741": "11500", # 45L AT PTP obr. 32 (Axis Minor)
"3756": "11501", # 150L ART Skoda M28(NOa) (Axis Minor)
"3766": "11502", # 47L AA Skoda 47L40(t) (Axis Minor)
"3772": "11503", # 65* INF Cannone da 65/17 (Axis Minor)
"3896": "11504", # L6/40(i) (Axis Minor)
"3898": "11506", # wz. 34-I (Axis Minor)
"4059": "11524", # 40M Nimrod (Hungarian)
"4065": "11532", # 39M Csaba (Hungarian)
"6873": "7461", # T-26C (r) nb: also 7463 (Finnish)
# NOTE: Doug Rimmer confirms that the "FT-17 730m(f)" and "FT-17 730(f)" were probably incorrectly renamed
# to "FT-17 730(f)" and "FT-17 730(m)". However, the 7124 -> 11479 GPID change is still probably correct.
# He also suggests that 7124 and 7128 are incorrectly-added duplicates, and the correct ones
# are 2542 and 2544.
"7124": "11479", # FT-17 730m(f) (German)
} ]
]
REVERSE_GPID_REMAPPINGS = [
[ row[0], { v: k for k,v in row[1].items() } ]
for row in GPID_REMAPPINGS
]
def get_remapped_gpid( vasl_version, gpid ):
"""Check if a GPID has been remapped."""
for remappings in GPID_REMAPPINGS:
# FUDGE! Early versions of this code (pre-6.5.0) always applied the remappings for 6.4.3,
# even for versions of VASL earlier than that. For simplicity, we preserve that behavior.
if compare_vasl_versions( remappings[0], "6.5.0" ) < 0 \
or compare_vasl_versions( vasl_version, remappings[0] ) >= 0:
gpid = remappings[1].get( gpid, gpid )
return gpid
def get_reverse_remapped_gpid( vasl_version, gpid ):
"""Check if a GPID has been remapped."""
for remappings in REVERSE_GPID_REMAPPINGS:
if compare_vasl_versions( vasl_version, remappings[0] ) >= 0:
gpid = remappings[1].get( gpid, gpid )
return gpid

@ -18,6 +18,7 @@ from vasl_templates.webapp import app, globvars
from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN
from vasl_templates.webapp.utils import TempFile, SimpleError
from vasl_templates.webapp.webdriver import WebDriver
from vasl_templates.webapp.vasl_mod import get_reverse_remapped_gpid
SUPPORTED_VASSAL_VERSIONS = [ "3.2.15" ,"3.2.16", "3.2.17" ]
SUPPORTED_VASSAL_VERSIONS_DISPLAY = "3.2.15-.17"
@ -221,12 +222,26 @@ def analyze_vsav():
return VassalShim.translate_vassal_shim_exception( ex, logger )
# translate any remapped GPID's back into their original values
# NOTE: We need to do this e.g. if we're analyzing a scenario that was created using VASL 6.5.0
# and it contains pieces that had their GPID's changed from 6.4.4. This kind of nonsense
# is probably unsustainable over the long-term, but we try to maintain some semblance of
# back-compatibility for as long as we can :-/
report2 = {}
for gpid,vals in report.items():
orig_gpid = get_reverse_remapped_gpid( globvars.vasl_mod.vasl_version, gpid )
if orig_gpid == gpid:
report2[ gpid ] = vals
else:
report2[ orig_gpid ] = vals
# return the results
logger.info( "Analyzed the VSAV file OK: elapsed=%.3fs\n%s",
time.time() - start_time,
pprint.pformat( report, indent=2, width=120 )
pprint.pformat( report2, indent=2, width=120 )
)
return jsonify( report )
return jsonify( report2 )
def _parse_analyze_report( fname ):
"""Read the analysis report generated by the VASSAL shim."""

Loading…
Cancel
Save