001/* 002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games. 003 * Copyright (C) 2000-2010 The Gridarta Developers. 004 * 005 * This program is free software; you can redistribute it and/or modify 006 * it under the terms of the GNU General Public License as published by 007 * the Free Software Foundation; either version 2 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU General Public License for more details. 014 * 015 * You should have received a copy of the GNU General Public License along 016 * with this program; if not, write to the Free Software Foundation, Inc., 017 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 018 */ 019 020package net.sf.gridarta.model.index; 021 022import java.io.File; 023import java.io.FileInputStream; 024import java.io.FileNotFoundException; 025import java.io.FileOutputStream; 026import java.io.IOException; 027import java.io.InputStream; 028import java.io.ObjectInputStream; 029import java.io.ObjectOutputStream; 030import java.io.OutputStream; 031import java.io.UnsupportedEncodingException; 032import java.util.Arrays; 033import java.util.concurrent.Semaphore; 034import net.sf.gridarta.model.archetype.Archetype; 035import net.sf.gridarta.model.gameobject.GameObject; 036import net.sf.gridarta.model.maparchobject.MapArchObject; 037import net.sf.gridarta.model.mapcontrol.DefaultMapControl; 038import net.sf.gridarta.model.mapcontrol.MapControl; 039import net.sf.gridarta.model.mapcontrol.MapControlListener; 040import net.sf.gridarta.model.mapmanager.MapManager; 041import net.sf.gridarta.model.mapmanager.MapManagerListener; 042import net.sf.gridarta.model.settings.ProjectSettings; 043import net.sf.gridarta.model.settings.ProjectSettingsListener; 044import net.sf.gridarta.utils.ConfigFileUtils; 045import net.sf.gridarta.utils.Xtea; 046import org.apache.log4j.Category; 047import org.apache.log4j.Logger; 048import org.jetbrains.annotations.NotNull; 049import org.jetbrains.annotations.Nullable; 050 051/** 052 * Maintains a {@link MapsIndex} for the maps directory. Changed maps are 053 * tracked and the index is updated. 054 * @author Andreas Kirschbaum 055 */ 056public class MapsIndexer<G extends GameObject<G, A, R>, A extends MapArchObject<A>, R extends Archetype<G, A, R>> { 057 058 /** 059 * The Logger for printing log messages. 060 */ 061 @NotNull 062 private static final Category log = Logger.getLogger(MapsIndexer.class); 063 064 /** 065 * A pending map may exist in {@link #mapsIndex} if a permit is available. 066 */ 067 @NotNull 068 private final Semaphore mapsIndexSemaphore = new Semaphore(1); 069 070 /** 071 * The {@link MapsIndex} being updated. 072 */ 073 @NotNull 074 private final MapsIndex mapsIndex; 075 076 /** 077 * The {@link MapManager} for loading map files. 078 */ 079 @NotNull 080 private final MapManager<G, A, R> mapManager; 081 082 /** 083 * The {@link ProjectSettings} instance. 084 */ 085 @NotNull 086 private final ProjectSettings projectSettings; 087 088 /** 089 * The object for synchronizing access to {@link #mapsDirectory} and {@link 090 * #newMapsDirectory}. 091 */ 092 @NotNull 093 private final Object syncMapsDirectory = new Object(); 094 095 /** 096 * The currently indexed maps directory. 097 */ 098 @Nullable 099 private File mapsDirectory; 100 101 /** 102 * The maps directory to index. If <code>null</code>, {@link #mapsDirectory} 103 * is valid. 104 */ 105 @Nullable 106 private File newMapsDirectory; 107 108 /** 109 * The object for synchronizing access to {@link #state}. 110 */ 111 @NotNull 112 private final Object syncState = new Object(); 113 114 /** 115 * The indexer state. 116 */ 117 @NotNull 118 private State state = State.INIT; 119 120 /** 121 * Indexer states. 122 */ 123 private enum State { 124 125 /** 126 * Indexer has been created but is not yet running. 127 */ 128 INIT, 129 130 /** 131 * Indexer is scanning the maps directory searching for files differing 132 * to the current index. 133 */ 134 SCAN, 135 136 /** 137 * Indexer is indexing maps differing from the current index. 138 */ 139 INDEX, 140 141 /** 142 * Indexer is idle; the current index is up-to-date. 143 */ 144 IDLE 145 146 } 147 148 /** 149 * The {@link MapManagerListener} attached to {@link #mapManager} to detect 150 * current map changes. 151 */ 152 private final MapManagerListener<G, A, R> mapManagerListener = new MapManagerListener<G, A, R>() { 153 154 @Override 155 public void currentMapChanged(@Nullable final MapControl<G, A, R> mapControl) { 156 // ignore 157 } 158 159 @Override 160 public void mapCreated(@NotNull final MapControl<G, A, R> mapControl, final boolean interactive) { 161 if (!mapControl.isPickmap()) { 162 mapControl.addMapControlListener(mapControlListener); 163 } 164 } 165 166 @Override 167 public void mapClosing(@NotNull final MapControl<G, A, R> mapControl) { 168 // ignore 169 } 170 171 @Override 172 public void mapClosed(@NotNull final MapControl<G, A, R> mapControl) { 173 if (!mapControl.isPickmap()) { 174 mapControl.removeMapControlListener(mapControlListener); 175 } 176 } 177 178 }; 179 180 /** 181 * The {@link MapControlListener} attached to all opened maps. 182 */ 183 @NotNull 184 private final MapControlListener<G, A, R> mapControlListener = new MapControlListener<G, A, R>() { 185 186 @Override 187 public void saved(@NotNull final DefaultMapControl<G, A, R> mapControl) { 188 final File file = mapControl.getMapModel().getMapFile(); 189 if (file != null) { 190 mapsIndex.setPending(file); 191 } 192 } 193 194 }; 195 196 /** 197 * The {@link ProjectSettingsListener} attached to {@link #projectSettings} 198 * for tracking changed maps directories. 199 */ 200 @NotNull 201 private final ProjectSettingsListener projectSettingsListener = new ProjectSettingsListener() { 202 203 @Override 204 public void mapsDirectoryChanged(@NotNull final File mapsDirectory) { 205 synchronized (syncMapsDirectory) { 206 newMapsDirectory = projectSettings.getMapsDirectory(); 207 } 208 } 209 210 }; 211 212 /** 213 * The {@link IndexListener} attached to {@link #mapsIndex} to detect newly 214 * added pending maps. 215 */ 216 @NotNull 217 private final IndexListener<File> indexListener = new IndexListener<File>() { 218 219 @Override 220 public void valueAdded(@NotNull final File value) { 221 // ignore 222 } 223 224 @Override 225 public void valueRemoved(@NotNull final File value) { 226 // ignore 227 } 228 229 @Override 230 public void nameChanged() { 231 // ignore 232 } 233 234 @Override 235 public void pendingChanged() { 236 mapsIndexSemaphore.release(); 237 } 238 239 @Override 240 public void indexingFinished() { 241 // ignore 242 } 243 244 }; 245 246 /** 247 * The {@link Runnable} scanning the maps directory and updating the index. 248 */ 249 @NotNull 250 private final Runnable runnable = new Runnable() { 251 252 @Override 253 public void run() { 254 while (!Thread.currentThread().isInterrupted()) { 255 updateMapsDirectory(); 256 indexPendingMaps(); 257 if (state == State.IDLE) { 258 try { 259 mapsIndexSemaphore.acquire(); 260 } catch (final InterruptedException ignored) { 261 Thread.currentThread().interrupt(); 262 break; 263 } 264 } 265 } 266 } 267 268 }; 269 270 /** 271 * The {@link Thread} executing {@link #runnable}. 272 */ 273 @NotNull 274 private final Thread thread = new Thread(runnable, "indexer"); 275 276 /** 277 * Creates a new instance. 278 * @param mapsIndex the maps index to update 279 * @param mapManager the map manager for loading map files 280 * @param projectSettings the project settings instance; defines the indexed 281 * maps directory 282 */ 283 public MapsIndexer(@NotNull final MapsIndex mapsIndex, @NotNull final MapManager<G, A, R> mapManager, @NotNull final ProjectSettings projectSettings) { 284 this.mapsIndex = mapsIndex; 285 this.mapManager = mapManager; 286 this.projectSettings = projectSettings; 287 reportStateChange(); 288 } 289 290 /** 291 * Starts indexing. Must not be called more than once. 292 */ 293 public void start() { 294 projectSettings.addProjectSettingsListener(projectSettingsListener); 295 mapManager.addMapManagerListener(mapManagerListener); 296 synchronized (syncMapsDirectory) { 297 newMapsDirectory = projectSettings.getMapsDirectory(); 298 } 299 mapsIndex.addIndexListener(indexListener); 300 thread.start(); 301 } 302 303 /** 304 * Stops indexing. Must not be called more than once or before {@link 305 * #start()} has been called. 306 * @throws InterruptedException if the current thread was interrupted 307 */ 308 public void stop() throws InterruptedException { 309 try { 310 thread.interrupt(); 311 thread.join(); 312 } finally { 313 projectSettings.removeProjectSettingsListener(projectSettingsListener); 314 mapManager.removeMapManagerListener(mapManagerListener); 315 for (final MapControl<G, A, R> mapControl : mapManager.getOpenedMaps()) { 316 if (!mapControl.isPickmap()) { 317 mapControl.removeMapControlListener(mapControlListener); 318 } 319 } 320 mapsIndex.removeIndexListener(indexListener); 321 } 322 323 synchronized (syncMapsDirectory) { 324 saveMapsIndex(); 325 } 326 } 327 328 /** 329 * Blocks the calling thread until all pending maps have been indexed. 330 * @throws InterruptedException if the current thread was interrupted during 331 * wait 332 */ 333 public void waitForIdle() throws InterruptedException { 334 synchronized (syncState) { 335 while (state != State.IDLE) { 336 syncState.wait(1000L); 337 } 338 } 339 } 340 341 /** 342 * Checks whether {@link #newMapsDirectory} has been set and updates {@link 343 * #mapsDirectory} accordingly. 344 */ 345 private void updateMapsDirectory() { 346 final File tmpMapsDirectory; 347 synchronized (syncMapsDirectory) { 348 if (newMapsDirectory == null || (mapsDirectory != null && mapsDirectory.equals(newMapsDirectory))) { 349 return; 350 } 351 352 setState(State.SCAN); 353 354 saveMapsIndex(); 355 356 tmpMapsDirectory = newMapsDirectory; 357 assert tmpMapsDirectory != null; 358 mapsDirectory = tmpMapsDirectory; 359 newMapsDirectory = null; 360 361 loadMapsIndex(); 362 } 363 mapsIndex.beginUpdate(); 364 scanMapsDirectoryInt(tmpMapsDirectory, ""); 365 mapsIndex.endUpdate(); 366 } 367 368 /** 369 * Saves {@link #mapsIndex} to its cache file if modified. 370 */ 371 private void saveMapsIndex() { 372 assert Thread.holdsLock(syncMapsDirectory); 373 if (mapsDirectory == null) { 374 return; 375 } 376 377 if (!projectSettings.saveIndices()) { 378 return; 379 } 380 381 if (!mapsIndex.isModified()) { 382 return; 383 } 384 385 assert mapsDirectory != null; 386 final File cacheFile = getCacheFile(mapsDirectory); 387 try { 388 final OutputStream outputStream = new FileOutputStream(cacheFile); 389 try { 390 final ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream); 391 try { 392 mapsIndex.save(objectOutputStream); 393 } finally { 394 objectOutputStream.close(); 395 } 396 } finally { 397 outputStream.close(); 398 } 399 if (log.isInfoEnabled()) { 400 log.info(cacheFile + ": saved " + mapsIndex.size() + " entries"); 401 } 402 } catch (final IOException ex) { 403 log.warn(cacheFile + ": cannot save cache file: " + ex.getMessage()); 404 } 405 } 406 407 /** 408 * Loads {@link #mapsIndex} from its cache file. 409 */ 410 private void loadMapsIndex() { 411 assert Thread.holdsLock(syncMapsDirectory); 412 assert mapsDirectory != null; 413 final File cacheFile = getCacheFile(mapsDirectory); 414 try { 415 final InputStream inputStream = new FileInputStream(cacheFile); 416 try { 417 final ObjectInputStream objectInputStream = new ObjectInputStream(inputStream); 418 try { 419 mapsIndex.load(objectInputStream); 420 } finally { 421 objectInputStream.close(); 422 } 423 } finally { 424 inputStream.close(); 425 } 426 if (log.isInfoEnabled()) { 427 log.info(cacheFile + ": loaded " + mapsIndex.size() + " entries"); 428 } 429 } catch (final FileNotFoundException ex) { 430 if (log.isDebugEnabled()) { 431 log.debug(cacheFile + ": cache file does not exist: " + ex.getMessage()); 432 } 433 mapsIndex.clear(); 434 } catch (final IOException ex) { 435 log.warn(cacheFile + ": cannot load cache file: " + ex.getMessage()); 436 mapsIndex.clear(); 437 } 438 } 439 440 /** 441 * Scans a directory for files. Adds all files found to {@link #mapsIndex}. 442 * @param dir the directory to scan 443 * @param mapPath the map path corresponding to <code>dir</code> 444 */ 445 private void scanMapsDirectoryInt(@NotNull final File dir, @NotNull final String mapPath) { 446 final File[] files = dir.listFiles(); 447 if (files == null) { 448 return; 449 } 450 451 for (final File file : files) { 452 if (file.isFile() && !file.getName().endsWith("~")) { 453 mapsIndex.add(file, file.lastModified()); 454 } else if (file.isDirectory() && !file.getName().equalsIgnoreCase(".svn") && !file.getName().equalsIgnoreCase(".dedit")) { 455 scanMapsDirectoryInt(file, mapPath + "/" + file.getName()); 456 } 457 } 458 } 459 460 /** 461 * Indexes one pending map from {@link #mapsIndex}. Does nothing if no 462 * pending map exists. 463 */ 464 private void indexPendingMaps() { 465 final File file = mapsIndex.removePending(); 466 if (file == null) { 467 setState(State.IDLE); 468 return; 469 } 470 471 setState(State.INDEX); 472 final long timestamp = file.lastModified(); 473 final MapControl<G, A, R> mapControl; 474 try { 475 mapControl = mapManager.openMapFile(file, false); 476 } catch (final IOException ex) { 477 if (log.isInfoEnabled()) { 478 log.info(file + ": load failed:" + ex.getMessage()); 479 } 480 return; 481 } 482 try { 483 final String mapName = mapControl.getMapModel().getMapArchObject().getMapName(); 484 if (log.isDebugEnabled()) { 485 log.debug(file + ": indexing as '" + mapName + "'"); 486 } 487 mapsIndex.setName(file, timestamp, mapName); 488 } finally { 489 mapManager.release(mapControl); 490 } 491 } 492 493 /** 494 * Returns the cache file for a given maps directory. 495 * @param mapsDirectory the maps directory 496 * @return the cache file 497 */ 498 @NotNull 499 private static File getCacheFile(@NotNull final File mapsDirectory) { 500 final byte[] key = new byte[16]; 501 final Xtea xtea = new Xtea(key); 502 final byte[] data; 503 try { 504 data = mapsDirectory.getAbsoluteFile().toString().getBytes("UTF-8"); 505 } catch (final UnsupportedEncodingException ex) { 506 throw new AssertionError(ex); // UTF-8 must be supported 507 } 508 final byte[] hash = new byte[8]; 509 final byte[] in = new byte[8]; 510 final byte[] out = new byte[8]; 511 int i; 512 for (i = 0; i + 8 < data.length; i++) { 513 System.arraycopy(data, i, in, 0, 8); 514 xtea.encrypt(in, out); 515 for (int j = 0; j < 8; j++) { 516 hash[j] ^= out[j]; 517 } 518 } 519 final int len = data.length % 8; 520 System.arraycopy(data, i, in, 0, len); 521 in[len] = (byte) 1; 522 Arrays.fill(in, len + 1, 8, (byte) 0); 523 xtea.encrypt(in, out); 524 for (int j = 0; j < 8; j++) { 525 hash[j] ^= out[j]; 526 } 527 528 final StringBuilder sb = new StringBuilder("index/maps/"); 529 for (int j = 0; j < 8; j++) { 530 sb.append(String.format("%02x", hash[j] & 0xff)); 531 } 532 sb.append(mapsDirectory.getName()); 533 final File file = ConfigFileUtils.getHomeFile(sb.toString()); 534 final File dir = file.getParentFile(); 535 if (dir != null && !dir.exists() && !dir.mkdirs()) { 536 log.warn("cannot create directory: " + dir); 537 } 538 return file; 539 } 540 541 /** 542 * Sets a new value to {@link #state}. Logs changed values. 543 * @param state the new state 544 */ 545 private void setState(@NotNull final State state) { 546 synchronized (syncState) { 547 if (this.state == state) { 548 return; 549 } 550 551 this.state = state; 552 reportStateChange(); 553 syncState.notifyAll(); 554 } 555 } 556 557 /** 558 * Logs a changed value of {@link #state}. 559 */ 560 private void reportStateChange() { 561 if (log.isDebugEnabled()) { 562 log.debug("state=" + state); 563 } 564 565 mapsIndex.indexingFinished(); 566 } 567 568}